Files
document-haness/docs/clean-architecture-backend-template/final/document.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다
- 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5
  (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를
  techviz 로 만들었다
- 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs
  돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다.
  Concept 이 인용한 코드가 SSOT 에 없어 뺐다
- candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

3.3 MiB
Raw Blame History

clean-architecture-backend-template — 상세 분석 (통합 정본)

성격: 정책 문서가 아니라 읽기 기록의 통합본이다. 62개 등록 leaf를 20편의 bounded analysis로 나눠 읽고, 그 결과를 한 문서로 합쳤다. 새로운 사실을 여기서 처음 만들지 않는다 — 모든 판정에 모듈 문서의 절 번호를 붙였고, 그쪽이 원본이다.

SSOT는 src/config/architecture/modules.json, 각 모듈의 CLAUDE.md, docs/**/support-matrix.md다. 이 문서와 그것들이 어긋나면 그쪽이 맞다.

리비전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
최초 기준선 a24ece9cf797f7ea647e33bf846b115208ed1ba5 (모듈 01~19)
등록 leaf 62 — COMPLETE 61 · EXCLUDED 1
추적 파일 6,747
main Java 4,614 파일 / 320,318 LOC
모듈 분석 제2부 65편 (최상위 22 · messaging 25 · grpc 18) — 합계 43,700줄
증거 evidence/raw 370 · evidence/meta 14 · 끊긴 참조 0
finding 총계 462 — P1 30 · P2 147 · P3 285 (델타 조정값, §14.2)
사이클 2 통독 추가 92 — P1 2 · P2 25 · P3 65 (23개 리프, §14.2)
분석 사이클 2 — 18개 리프 재검증(번복 1건) + 23개 리프 통독(1회 재작업, §14.2)
교차 스코프 §A99 COMPLETE
앵커 SRC 173행 · EVD 81행
소스 트리 변경 없음

0. 이 문서를 읽는 법

이 저장소는 Clean Architecture 템플릿인 동시에 두 개의 벤더드 플랫폼(messaging 25 leaf, gRPC 18 leaf)을 품고 있다. 그래서 leaf 수(62)가 계층 수보다 훨씬 많고, 읽는 순서가 중요하다.

읽는 순서를 하나만 고른다면 이렇다.

  1. §1 숫자 지도 — 무엇이 얼마나 있고 무엇이 출하되는가.
  2. §2 경계 — 무엇이 그 구조를 강제하는가. 여기가 이 저장소의 실제 설계다.
  3. §7 반복된 네 가지 형태 — 62개 leaf를 읽고 나서 남는 것. 개별 결함보다 이쪽이 값이 크다.
  4. §8 확인된 문제 — 고칠 것.

깊이가 필요하면 제2부로 간다. 제1부의 각 절이 가리키는 자리가 거기 있다. 예를 들어 §A05(persistence-jpa)는 4,674줄이고 제1부 §4.1은 그 요약이다.

표기. (05 §23)은 제2부 §A05 안의 §23을 뜻한다. 제2부는 분석하는 동안 analysis/ 아래에 파일로 나뉘어 있던 것을 그대로 옮겨 온 것이고, 파일 이름의 번호가 절 번호가 됐다.


1. Project map — 숫자로 먼저

1.1 빌드와 레지스트리

Java 21 · Spring Boot 4.0.8 · Gradle 9.0.0 멀티모듈. src/settings.gradle은 16줄이고, 실제 검증은 included build build-logicca.architecture-registry settings plugin이 한다.

src/config/architecture/modules.json이 SSOT다. leaf의 존재, 경로, 허용 project dependency, runtime membership을 전부 여기서 읽는다. 등록되지 않은 디렉터리는 빌드에 포함되지 않는다 — fail-closed.

이 성질이 이 저장소의 형태를 결정했다. docs/jpa/repository-adaptation.md가 그 결정을 기록한다:

원래 설계는 JPA 플랫폼을 18개 Stable Gradle 모듈 + 7개 Experimental 모듈로 모델링한다. 그런데 이 저장소는 fail-closed 레지스트리를 쓴다. Gradle 프로젝트를 25개 더 만드는 건 AGENTS.md의 HARD-STOP #5 위반이다. 그래서 설계상의 모듈이 이 leaf 안의 패키지 경계가 됐다.

같은 압력이 messaging과 gRPC에서는 반대로 풀렸다 — 그쪽은 실제로 leaf 25개와 18개를 등록했다. 결과적으로 이 저장소에는 두 가지 크기의 leaf가 공존한다: 패키지가 모듈 역할을 하는 거대 leaf(persistence-jpa 350 main / 27,744 LOC)와, 파일 몇 개짜리 leaf(messaging-schema-json 1 main).

1.2 가족별 분모와 출하 여부

가족 leaf 출하 추적 파일 main Java 담당 문서
core/composition 5 5 1,859 1,295 01·02·03·18
adapter/outbound 10 10 2,515 1,713 0413
adapter/inbound 4 2 1,443 982 1417
messaging 플랫폼 25 18 549 364 19
gRPC 플랫폼 18 0 381 260 20
합계 62 35 6,747 4,614

런타임 조합은 둘 — app-bootstrap(33 leaf), sample-portfolio(8 leaf, 사용자 지시로 분석 제외).

1.3 leaf별 규모 (main Java 기준 상위)

leaf main LOC 스테레오타입 비율
application-core 885 35,772 0 0.0%
adapter/inbound/graphql 408 26,303 3 0.7%
adapter/inbound/web 397 27,473 42 10.6%
adapter/outbound/persistence-mongo 351 22,924 4 1.1%
adapter/outbound/persistence-jpa 350 27,744 28 8.0%
adapter/outbound/cache-redis 314 32,082 1 0.3%
adapter/outbound/httpclient 260 15,004 0 0.0%
sample-portfolio 201 9,387 50 24.9%
grpc (12 leaf 합) 194 14,778 1 0.5%
adapter/outbound/notification 171 14,695 5 2.9%
adapter/inbound/websocket 169 12,784 5 3.0%
app-bootstrap 149 12,380 48 32.2%
adapter/outbound/objectstorage 147 14,336 5 3.4%
messaging-core-api 85 3,948 0 0.0%
adapter/outbound/fileserver 78 12,707 2 2.6%
grpc-advanced (6 leaf 합) 66 3,948 0 0.0%
shared-contract 53 2,628 0 0.0%
grpc-core-api 32 1,897 0 0.0%
domain-core 7 104 0 0.0%
전체 4,614 320,318 206 4.5%

1.4 이 표에서 읽어야 할 것

(1) 프레임워크 표면이 4.5%다. main Java 4,614개 중 Spring 스테레오타입(@Component·@Service· @Repository·@Configuration·@AutoConfiguration·@Controller·@RestController· @(Rest)ControllerAdvice)을 가진 것은 206개이고, 그중 98개가 두 합성 루트에 있다. 나머지 4,408개(95.5%)는 프레임워크 애노테이션이 없다.

선언된 아키텍처 그대로다. application-core 885개, httpclient 260개, messaging-core-api 85개, grpc-core-api 32개가 전부 0이다.

그리고 이 사실이 §7의 지배적 결함 형태를 설명한다 — 코드의 절대다수가 프레임워크와 무관하게 옳게 작성돼 있고, 그것을 런타임에 연결하는 일은 4.5%의 좁은 표면에서만 일어나며, 결함은 거의 전부 그 표면에서 발생한다.

(2) 자동설정 루트가 저장소 전체에 13개뿐이다.

adapter/inbound/graphql            GraphQlRootAutoConfiguration
adapter/inbound/web                WebMvcPlatformAutoConfiguration
                                   WebFluxPlatformAutoConfiguration
adapter/outbound/cache-redis       RedisSdkAutoConfiguration
adapter/outbound/messaging         MessagingBridgeRootAutoConfiguration
adapter/outbound/persistence-mongo MongoRootAutoConfiguration
app-bootstrap                      FileserverPlatformAutoConfiguration
                                   HttpClientPlatformAutoConfiguration
                                   PersistenceJpaRootAutoConfiguration
                                   DisabledMessagingSentinelAutoConfiguration
                                   NotificationRootAutoConfiguration
                                   AdapterActivationAutoConfiguration
messaging-spring-boot-starter      MessagingPlatformRootAutoConfiguration
grpc-spring-boot-starter           GrpcPlatformAutoConfiguration

8개 .imports 파일 / 13개 클래스. 이 목록이 "무엇이 조립될 수 있는가"의 전체 집합이다.

(3) build-only가 27 leaf / 729 파일 / main 479개다.

adapter-inbound-websocket   253 파일 / main 169   ← 단일 최대 build-only leaf
grpc + grpc-advanced 18개    381 파일 / main 260   ← 가족 전체가 build-only
messaging 7개                 77 파일 / main  42
adapter-inbound-grpc          18 파일 / main   8

이 구분이 이 문서 전체의 심각도 축이다 — 런타임에 오르지 않는 leaf의 미조립은 오늘의 사고가 아니라 채택 시점의 부채다(17 §26.6 · 19 §1.1 · 20 §1.1).


2. Architectural boundaries — 무엇이 경계를 강제하는가

이 저장소의 핵심은 "규칙을 문서에 적었다"가 아니라 **"규칙을 어기면 빌드나 부팅이 실패한다"**로 바꾼 장치들이다. 실제로 도는 것만 정리한다.

2.1 강제 장치 목록

장치 시점 무엇을
modules.json allowed_dependencies build leaf 간 project edge
verifyCleanArchitectureDependencies build 실제 api/implementation/compileOnly/runtimeOnly 집합 ↔ 레지스트리 allowlist
verifyRuntimeModuleMembership build 어떤 leaf가 어떤 런타임 조합에 들어가는가
verifyPublicPathSnapshot · verifyDependencyLocks build 공개 경로·의존 잠금
CleanArchitectureTest (ArchUnit) test production classes 전체에 규칙 14종 + 위반/허용 픽스처 76개
JpaModuleBoundaryTest test JPA leaf의 패키지 카탈로그 정확한 동등성 + 사이클
ConditionalTransportCompositionContractTest 별도 레인 build-only 전송이 어떤 런타임에도 오르지 않음
MessagingPublicSurfaceContractTest app-bootstrap 레인 vendor 타입 노출 ↔ api 선언 일치
verifyDocumentedLeafCount build 문서의 leaf 수 진술 ↔ 레지스트리
verifyJpaSqlConstructionSafety build 벤더 소스의 SQL 문자열 연결
각 leaf의 startup validator 부팅 §5.3

2.2 CleanArchitectureTest의 규칙 14종

app-bootstrap이 소유하고 production classes 전체를 대상으로 돈다. 위반/허용 합성 픽스처 76개가 각 규칙의 양방향을 고정한다 — 규칙이 통과만 하는 것으로는 그 모양을 알 수 없기 때문이다 (18 SRC-159).

testkit/archJpaArchitectureRules가 그중 6종을 담당한다.

규칙 막는 것
noEntityFromWeb() 컨트롤러가 엔티티 반환 → 트랜잭션 밖 lazy association 직렬화
entitiesFollowPortableMappingRules() final 엔티티(프록시 불가 → 모든 lazy 참조가 eager), no-arg 생성자
entitiesStayOutOfWebPackages() web 패키지 안의 엔티티는 노출될 엔티티
domainDoesNotDependOnHibernate() 도메인이 ORM에 의존
tenantScopedRepositoriesDoNotInheritBroadCrud() tenant-scoped 엔티티 리포지토리가 CRUD 상속
noGenericRepository() 플랫폼이 CrudRepository 재구현

tenantScopedRepositoriesDoNotInheritBroadCrud의 설명이 이 팩의 성격을 보여준다:

이름은 중요한 속성이 아니다. tenantId를 가진 엔티티에 대해 JpaRepository를 확장하는 OrderRepositoryfindById(UUID), findAll(), deleteById(UUID)를 노출한다 — 전부 tenant-blind, 전부 상속, 그리고 리뷰어가 볼 만한 어디에도 적혀 있지 않다.

2.3 검증된 경계 — 실제로 성립하는 것

domain-core (01). 허용 의존 0개. main 7파일 / 104 LOC. 이 모듈이 소유하는 것은 재사용 가능한 도메인 "내용"이 아니라 도메인 모델링 계약이다 — ResourceId<SELF>, IdFactory<T>, 그리고 @ValueObject / @AggregateRoot / @DomainEvent 세 개의 stereotype marker. 두 런타임 조합 모두의 membership에 들어가지만, 그건 런타임 closure 포함 계약이지 Spring bean을 갖는다는 뜻이 아니다.

shared-contract (02). production dependency block이 비어 있고 Java stdlib만 쓴다. 이 모듈의 아키텍처적 의미는 "공통 유틸리티"가 아니라 서로 다른 outer module이 한쪽 adapter의 타입에 의존하지 않고 합의할 수 있는 중립 계약 지점이다.

그 방향성의 직접 증거: GraphQL persisted-operation registry가 자기 저장소 인터페이스를 선언하지 않고 OperationalRecordStorePort(중립 CAS port)에 의존한다. inbound adapter가 선언한 인터페이스를 outbound가 구현하는 역방향 dependency를 피한다.

grpc-core-api (20 §2.1). 이 저장소에서 본 가장 강한 형태의 자기 제약이다.

apply plugin: 'java-library'
dependencies {
}

dependencies {}비어 있다. io.grpc가 컴파일 클래스패스에 아예 없으므로 "framework-free"가 문서가 아니라 빌드로 강제된다. main 32파일 / 1,897 LOC가 Java stdlib만으로 선다.

소스에서 io.grpc가 3번 등장하는데 전부 javadoc 산문이고, 그 내용이 왜 타입을 쓰지 않는지를 설명한다:

The canonical gRPC status codes, mirrored so that grpc-core-api stays free of io.grpc. ... a failure context that names io.grpc.Status would put the transport inside the contract that exists to describe what the transport did.

messaging-core-api (19 §2.1). 25개 leaf main 전체에서 예외 타입을 문자열로 판별하는 코드 (getClass().getName().contains / getSimpleName().contains / getMessage().contains)가 0건. 이 규칙은 앞선 모듈들에서 반복해서 깨진 것이다(11 httpclient, 13 notification).

Stable → advanced 금지 (20 §2.2). 레지스트리 0위반 · 소스 0참조. grpc-spring-boot-starterallowed_dependencies는 Stable 10개 leaf뿐이고, src/grpc 전체에서 dev.caskeleton.grpc.advanced 참조가 0건이다. grpc-advanced의 CLAUDE.md가 별도 디렉터리·별도 Gradle prefix를 쓴 이유를 명시한다 — "그 불변 조건을 registry의 allowed_dependencies만으로 기계 검증할 수 있게 하기 위해서."

(단, 이 규칙의 runtime 절반은 실행되지 않는다 — §8.2 참조.)

adapter-inbound-grpc ↛ gRPC 가족 (20 §2.3). allowed_dependencies[domain-core, application-core, shared-contract]다. 이 가족을 볼 수 없다.

이것이 messaging과의 결정적 차이다. messaging은 messaging-spring-boot-starterapp-bootstrap 의존으로 들어가면서 18개 leaf가 출하 아티팩트에 실렸고, 그 결과 MSG-015(서로 모르는 두 스택)가 실재 문제가 됐다. gRPC 가족은 아직 그 선을 넘지 않았고, 넘지 않았다는 사실을 문서가 정확히 말한다.

2.4 경계가 열려 있는 지점

MSG-015 — 서로 모르는 Kafka 스택 둘이 한 아티팩트에 (19 §6.5, 가족 문서가 P0 미해결로 표시).

app-bootstrap seam messaging platform
설정 클래스 bootstrap/messaging/KafkaSenderConfig autoconfigure/KafkaMessagingAutoConfiguration
producer bean kafkaSeamProducer : Producer<String,String> messagingKafkaProducer : Producer<byte[],byte[]>
조건 @ConditionalOnProperty("app.messaging.broker", havingValue="kafka") root의 app.messaging.enabled=true → 선택 → broker=kafka
의존 방향 adapter-outbound-messaging은 platform에 의존하지 않음 platform은 adapter를 모름

bean 이름 충돌은 해소됐고, KafkaSenderConfig의 javadoc이 그 과정을 적는다:

Sharing the method name messagingKafkaProducer made the context refuse to start with a BeanDefinitionOverrideException, and a type-scoped @ConditionalOnMissingBean would have been worse: whichever configuration lost the race would leave its own stack without a producer while the other stack's, with incompatible serializers, sat in its place.

판단은 옳다. 그러나 결과적으로 이 결함의 유일한 가시적 증상이 제거됐다. 지금은 부팅이 성공하고 두 스택이 조용히 공존한다.

그리고 정정할 부분이 하나 있다. 가족 문서는 안전의 근거를 "지금 안전한 이유는 설계가 아니라 기본값이다 — app.messaging.enabled=false"에 둔다. 그런데 seam 스택은 enabled를 보지 않고 broker만 본다. 따라서 enabled=false두 스택 중 하나만 막는다.

리액티브 web 절반 (14 §40.1). adapter-inbound-web의 리액티브 29개 파일은 어떤 출하 배포에서도 활성화될 수 없다.

messaging-rabbit (19 §6.2). 20 main / 2,443 LOC가 출하되지만 선택할 수 없다 — MessagingProviderSelection.BROKERS_WITHOUT_A_TRANSPORT에 등록돼 있고 RabbitChannelPublisher 구현이 저장소에 없다. 코드는 fail-closed로 정직하고, 운영 문서만 그 사실을 말하지 않는다.


3. Representative execution paths

3.1 HTTP 요청 — 출하 경로

adapter-inbound-webapplication-core port → outbound adapter.

오류 응답은 Envelope 계약이 낸다. GlobalExceptionHandler(22 handler + 6 override) + EnvelopeBodyAdvice + ErrorResponseFactory + ClientSafeErrorMessages가 컴포넌트 스캔으로 등록된다.

RFC 9457 problem+json 계약 23개 파일은 등록되지 않는다 (14 §8.1). 합성 루트가 다섯 web 패키지를 컴포넌트 스캔에서 제외하고, 그 이유를 CaSkeletonApplication의 javadoc이 적는다:

The web platform's error, budget and operation packages are here for a third reason. Their advices and controllers need beans that only exist when the corresponding platform auto-configuration is active, and a component scan finds them regardless — so an all-off or partially configured deployment failed to start on an unsatisfied dependency rather than simply not installing the control. Ownership by auto-configuration is what ties a control's presence to its dependency's.

진단도 조치의 방향도 옳다. 자동설정도 존재하고 .imports에 등록돼 있으며 출하 컨텍스트에 실제로 import된다(app-bootstrap/build.gradle:98이 이 leaf를 물고, 저장소의 유일한 AutoConfigurationImportFilter는 JPA 전용이다). 그런데 그 자동설정이 등록하는 @Bean 13개 (MVC)·10개(WebFlux)가 전부 협력자다:

MVC   : InMemoryWebOperationCatalog · WebBudgetCatalog · ProblemCatalog · WebProblemFactory ·
        WebValidationExceptionMapper · WebValidationIssueMapper · WebWireTypeManifest ·
        WebUriPolicy · WebMethodPolicy · webStrictObjectMapper ·
        WebMvcRequestIdFilter · WebMvcEvidenceFilter · webMvcRequestContextConfigurer

스캔에서 제외된 다섯 패키지의 컴포넌트 여섯 개는 어느 쪽도 소유하지 않는다:

컴포넌트 패키지 애노테이션 main 참조
WebMvcProblemExceptionHandler mvc.error @RestControllerAdvice @Order 0
WebFluxProblemExceptionHandler webflux.error @RestControllerAdvice @Order 0
WebMvcBudgetExceptionHandler mvc.budget @RestControllerAdvice @Order 0
WebMvcBudgetFilter mvc.budget (없음) 0
OperationHttpController mvc.operation @RestController 0
ReactiveOperationHttpController webflux.operation @RestController 0

ProblemCatalogWebProblemFactory는 빈으로 존재하고, 그것을 사용하는 @RestControllerAdvice가 존재하지 않는다. javadoc이 말한 "Ownership by auto-configuration"에서 dependency 쪽만 소유되고 control 쪽은 소유되지 않았다.

실패 시나리오. 팀이 ProblemCode.VALIDATION_FAILED로 분기하는 클라이언트 SDK를 작성한다. WebProblemFactoryTest(122줄)·ProblemCatalogTest(99줄)·WebValidationExceptionMapperTest(125줄)가 전부 통과하고, /v3/api-docs에도 problem 스키마가 기여되지 않아 계약 불일치를 볼 방법이 없다. 배포된 API는 422 대신 Envelope의 400을 내고 code 필드는 ProblemCode가 아니라 OperationalError 어휘다. SDK의 모든 분기가 빗나간다.

같은 경로의 다른 미조립:

  • 용량 보호 계층 41 main 파일이 자기 테스트 픽스처 안에서만 실행된다 (14 §16.1).
  • 멱등 실행 계층·durable-operation 표면이 픽스처에서만 조립된다 (14 §20.1).
  • 요청 식별자를 클라이언트가 고를 수 없다는 정책이, 뒤에 도는 다른 배선 필터에 의해 뒤집힌다 (14 §32.1).
  • forwarded 헤더 신뢰 판정이 Nginx 설정에만 있고 그것을 위해 쓴 Java 정책 421 LOC은 배선되지 않는다 (14 §32.2).
  • 배선된 캐시 필터의 no-store가 배선된 조건부 읽기 경로를 무력화하고, 둘을 조정하려고 만든 패키지는 참조 0이다 (14 §24.1).

3.2 트랜잭션 — application-core 포트에서 PostgreSQL local timeout까지

이 저장소에서 가장 밀도 높은 경로다 (03 §4, 05 §3).

포트가 둘이고 같은 빈이 둘 다 구현한다.

application-core:
  TransactionPort           inWrite / inRootWrite / inRead / inNew
  PolicyTransactionPort  →  extends TransactionPort
                            + inTransaction(TransactionRequest, Supplier) : TransactionResult<T>
adapter:
  SpringTransactionPort implements PolicyTransactionPort
    ├─ 레거시 4모드 → 미리 만들어 둔 TransactionTemplate 3개
    └─ inTransaction  → SpringPolicyTransactionPort (package-private 위임체)

템플릿을 생성 시점에 미리 만들어 두는 이유: TransactionTemplate은 thread-safe지만 mutable이라, 호출마다 propagation/readOnly를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 race window가 생긴다.

inRootWriteREQUIRES_NEW로 suspend해서 "root인 척"하지 않는다. 그러면 호출자 트랜잭션과 독립 커밋되는 silent 의미 변경이 생기므로, 대신 fail-fast한다:

public <T> T inRootWrite(Supplier<T> action) {
  if (TransactionSynchronizationManager.isActualTransactionActive()) {
    throw new NestedRootTransactionRejectedException();
  }
  return executeLegacy(writeTemplate, action);
}

정책 기반 실행기의 반환 타입이 결과 대수를 만든다.

변형 의미
Committed(value, operationId) 물리 커밋 확인됨
Participating(value) 바깥 트랜잭션에 참여 — 커밋을 주장하지 않는다
DeterminateRollback(failure) 롤백 확정
Indeterminate(operationId, lastObservedPhase, reconciliationReference) 결과 불명 — replay 권한을 주지 않는다
CommittedWithPostCommitFailure(value, operationId, operationalFailure) 커밋됐지만 afterCommit 훅이 실패

핵심 실행 루틴:

tracker.observe(TransactionPhase.COMMIT_REQUESTED);
try {
  transactionManager.commit(status);
} catch (RuntimeException commitFailure) {
  if (sentinel.commitAcknowledged()) {
    return CommittedWithPostCommitFailure(...);          // afterCommit이 이미 왔다
  }
  if (sentinel.rolledBack()
      || commitFailure instanceof UnexpectedRollbackException
      || TransactionRetryClassifier.isReplayCandidate(commitFailure)) {
    return DeterminateRollback(translate(commitFailure)); // 롤백 확정
  }
  return Indeterminate(operationId, tracker.lastObserved(), empty());  // 모른다
}

데드라인이 3단으로 좁혀진다.

획득 전:
  required = connectionTimeout + beginBudget + minimumActionWindow + completionMargin
  remaining < required                → TransactionAdmissionException
  springTimeoutSeconds < 1            → TransactionAdmissionException   (Spring은 초 단위)

begin 이후:
  statementWindow = min(callRemaining - completionMargin, springRemaining) - transactionMargin
  statement = min(settings.statementTimeout, statementWindow)
  lock      = min(settings.lockTimeout, statement - lockMargin)
  idle      = min(settings.idleGuardTimeout, callRemaining - completionMargin)
  셋 중 하나라도 1ms 미만              → TransactionAdmissionException

그리고 DB에 밀어 넣는다:

"select set_config('statement_timeout', ?, true)"
"select set_config('lock_timeout', ?, true)"
"select set_config('idle_in_transaction_session_timeout', ?, true)"

세 번째 인자 truetransaction-local이다. SET statement_timeout = ?가 아닌 이유는 두 가지 — SET은 파라미터 바인딩 전에 파싱되어 syntax error가 나고, set_config 함수 호출은 값이 statement text에서 빠진다.

3.3 메시지 발행 — messaging 플랫폼

DefaultMessagePublisher가 유일한 publish 경로이고, 단계 순서가 고정돼 있다 (19 §3.1):

resolve → authorize → encode → admit → lease → send → normalize

각 단계 위치의 근거가 javadoc에 있다.

  • destination·access가 encode보다 먼저 — 인가되지 않은 publish가 payload를 직렬화하면 claim-check나 로그가 그 바이트를 들고 있게 된다.
  • encode가 admit보다 먼저 — admission 한도가 바이트 기준이고, 바이트 수는 인코딩 전에 알 수 없다.
  • runtime lease가 send 직전 마지막 — 이미 in-flight 카운트에 잡힌 메시지 밑에서 rotation이 transport를 갈아끼우지 못하게.

그리고 획득한 permit·lease는 성공·실패·예외·취소 모든 경로에서 정확히 한 번 반환된다 — "실패 경로에서 새는 permit은 실패 한 번에 하나씩 줄어들다 아무것도 받지 않게 되는 limiter다."

증거와 결론이 분리돼 있다 (19 §3.2). PublishEvidence가 compact constructor에서 표현 불가능한 조합을 거부한다:

if (!brokerAccepted && confirmationLevel != ConfirmationLevel.NONE)
    throw new IllegalArgumentException("confirmation level requires broker acceptance: " + ...);
if (transmission == TransmissionEvidence.NOT_TRANSMITTED && brokerAccepted)
    throw new IllegalArgumentException("untransmitted message cannot be broker accepted");
지점 completion 근거
access 거부 / encode 실패 REJECTED + notTransmitted() "아무것도 이 프로세스를 떠나지 않았으므로 결과는 확정적이다. ambiguous로 보고하면 어떤 브로커도 보지 못한 메시지에 대해 caller를 reconciliation으로 보낸다."
준비 중 데드라인 소진 REJECTED 아직 전송 전
transport 단계 실패/데드라인 AMBIGUOUS + retryable=true "요청이 wire 위에 있었으므로 브로커가 들고 있을 수 있다."

sanitized(Throwable)타입만 남기고 메시지를 버린다 — "드라이버 메시지는 routing key, payload 조각, connection string을 담을 수 있고 FailureDescriptor는 로깅·export되도록 설계됐다."

오늘 실제로 배포 가능한 브로커는 Kafka 하나다 (§2.4).

3.4 gRPC — 채택 시점 경로

ca-skeleton.grpc.platform.enabled=true(fail-closed, matchIfMissing=false)가 GrpcPlatformAutoConfiguration을 켜고 @Bean 9개를 만든다 — 전부 프로파일·정책이다:

GrpcExecutorProfile · GrpcServerProfile · GrpcAdmissionController · GrpcServiceHealthRegistry ·
GrpcReflectionPolicy · GrpcAdminExposurePolicy · GrpcDrainPolicy · GrpcContextBinder · GrpcErrorMapper

서버도, 인터셉터 체인도, 서비스 어댑터 등록도 만들지 않는다 (20 §3.4). 그 담당 타입들이 전부 main 참조 0이다 — GrpcServerInterceptorChain, ProtovalidateGrpcInterceptor, GrpcIdempotencyInterceptor, GrpcServiceAdapter, 스트리밍 기계 4종, GrpcTypedStubFactory.

GrpcServerInterceptorChain의 javadoc이 자기 존재 이유를 적는다:

That reversal is the reason this class exists rather than a list literal at the call site. ServerInterceptors.intercept wraps each interceptor around the previous one, so the last one passed is the outermost at runtime — the opposite of how the order reads. Every codebase that builds this list by hand gets it backwards at least once, and the symptom is an exception boundary that catches nothing.

18 leaf 전부 runtime_memberships: []이므로 이 경로는 아직 어떤 배포에도 없다.

3.5 알림 발송 — 논리적 수락과 provider 불확실성

application-core논리적 수락(요청을 받아 저장)과 provider 결과(실제 발송)를 분리한다 (03 §12). 어댑터(13)가 8개 provider를 갖고, 그중 공유 계약(ProviderAdapterContract)을 상속하는 것은 3개뿐이며 강제 장치가 없다 (13 §29.2).

이 경로에서 확인된 P2 중 무거운 것:

  • SINGLE 전용 가드가 먼저 던져 다중 타깃 검증 전체(순환 탐지 포함)가 도달 불가이고, 그것을 검증한다는 테스트가 hasMessageContaining("strategy")다른 가드에 걸려 통과한다 (13 §9).
  • "Every reveal is auditable"을 선언한 AccessContext의 세 필드를 읽는 코드가 저장소에 0개. 감사 싱크는 존재하고 다른 경로는 사용 중이다 (13 §17.1).
  • 클라이언트 제공 Web Push 엔드포인트가 SSRF 가드를 지나지 않는다. 약한 검사의 private 사본을 쓴다 (13 §25.1).
  • "상한을 두고 읽는다"는 본문 핸들러가 ofByteArray()전부 읽은 뒤 자른다 (13 §25.2).
  • FCM만 "커밋 후 응답 손실 = ambiguous" 규칙 밖이고, 배치라 한 번의 손실이 배치 크기만큼 영향을 준다 (13 §29.1).

4. Data and state

4.1 관계형 — persistence-jpa (605 파일 / main 350 / 27,744 LOC)

이 leaf는 어댑터가 아니라 플랫폼이다. 원래 설계의 25개 모듈이 fail-closed 레지스트리 때문에 이 leaf 안의 패키지 경계가 됐다(§1.1). top-level child 패키지 22개가 각각 원래는 별도 라이브러리다.

계약층 api가 프레임워크를 모른다. 여기 있는 결정이 아래층 전부를 규정한다.

이름을 타입으로 만든다 — PersistenceOperationName, QueryName, ConstraintCode, FetchPlanName, BulkOperationName, WorkQueueName, JsonPathName, TenantId가 전부 같은 패턴이다:

public record PersistenceOperationName(String value) {
  private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}");
}

목적은 하나다 — 이 값은 metric tag / trace / retry policy의 키가 되므로 엔티티 id, tenant id, SQL 조각, 요청 스코프 값을 담을 수 없다. 카디널리티 상한이 관례가 아니라 타입 차원에서 보장된다. "메트릭 태그에 id 넣지 마세요"를 코드 리뷰 규칙이 아니라 정규식을 통과 못 하면 생성자가 던진다로 만들었다.

핵심 불변식이 하나 있다.

// JpaFailureContext
if (completionUnknown && retryable) {
  throw new IllegalArgumentException("completion unknown failures are never retryable");
}

이게 이 플랫폼 전체가 존재하는 이유다. 커밋됐을지도 모르는 작업을 자동 재실행하는 것이 할 수 있는 최악의 일이고, 그래서 타입 시스템이 그 상태를 표현하는 것 자체를 거부한다. 이중 방어도 있다 — TransactionCompletionUnknownException 생성자가 forceCompletionUnknown(context)로 컨텍스트를 다시 만들어, 정책 버그가 retryable한 completion-unknown을 만들 방법이 없다. RetryProfileCOMPLETION_UNKNOWN을 화이트리스트에 넣는 것을 생성자에서 거부한다.

57P01 발견. completion-unknown 판정 규칙은 좁다 — 커밋 단계에서 발생했고 동시에 드라이버가 어느 쪽인지 말해주지 못했을 때만이다. 그 규칙에 57P0x가 들어온 경위가 기록돼 있다:

"커밋 모호성은 SQLSTATE class 08뿐"은 틀렸다. 커밋이 in-flight인 백엔드에 pg_terminate_backend를 하면 connection-class가 아니라 57P01(admin_shutdown)이 보고되고 — 그리고 그게 도착할 때 커밋 레코드는 이미 WAL에 있을 수 있다. 컨테이너 레인이 그걸 보여줬고, CommitAmbiguityContractTest가 SQLSTATE를 직접 assert해서 규칙이 다시 조용히 좁아지지 못하게 한다.

넓히지 않는 이유도 적혀 있다 — 커밋 단계의 모든 커넥션 에러를 completion-unknown으로 표시하면 평범한 풀 고갈과 서버 재시작이 reconciliation 큐로 밀려들고, 운영자는 그 큐를 읽지 않고 비우는 습관을 배운다.

Flyway가 스키마를 소유하고, 그것이 강제 가능하다. 런타임 롤에서 DDL 권한을 뺐기 때문이다:

select has_schema_privilege(current_user, current_schema(), 'CREATE') as create_on_schema,
       has_database_privilege(current_user, current_database(), 'CREATE') as create_on_database

질문은 설정이 아니라 서버가 답한다. 롤의 유효 권한은 직접 grant, 상속된 롤 멤버십, PUBLIC grant, 스키마 소유권에서 나오고, 어떤 배포 매니페스트를 읽어도 그 조합을 신뢰성 있게 재구성할 수 없다. has_schema_privilege는 할 수 있다.

마이그레이션 스트림이 8개로 갈라져 있다.

db/migration/postgresql/            flyway_schema_history          (legacy/adoption)
db/migration/jpa/core/              flyway_jpa_core_history
db/migration/jpa/idempotency/       flyway_jpa_idempotency_history
db/migration/jpa/outbox-storage/    flyway_jpa_outbox_storage_history
db/migration/jpa/outbox-polling/    flyway_jpa_outbox_polling_history
db/migration/jpa/inbox/             flyway_jpa_inbox_history
db/migration/jpa/fileserver/        (capability 스트림)
db/migration/jpa/notification-platform/ flyway_jpa_notification_history
db/experimental-rls/                Stable location이 절대 적용하지 않음

이유가 NotificationSchemaStream javadoc에 명확하다:

뻔한 해법 — 디렉터리를 primary location 목록에 추가 — 이 틀린 이유: 두 트리가 다 V1부터 번호를 매기고, 공유 history 테이블은 V1__notification_platform_coreV1__initial_schema를 같은 버전으로 만든다. Flyway는 두 번째를 거부하거나, 더 나쁘게는 resolution 순서에 따라 하나를 기록하고 하나를 건너뛴다.

capability_schema_registry가 그 접착제다. 각 capability 스트림의 V1이 (1) 선행조건 검사 (DO $$ ... RAISE EXCEPTION), (2) 테이블 생성, (3) 자기를 INSTALLED_INACTIVE로 등록을 한다. 스키마가 적용된 것과 capability가 사용 승인된 것이 분리되고, 어댑터가 런타임에 ACTIVE인지 확인한다.

owner-safe 상태 기계 네 개(idempotency / outbox-storage / outbox-polling / inbox)가 공통 패턴을 공유한다:

  1. capability guard — 레지스트리에서 ACTIVE + 정확한 epoch/revision
  2. same-resource primary write transaction guard — hasResource(dataSource)
  3. row lock 먼저, clock_timestamp() 그 다음 — 행이 바뀔 수 없게 된 뒤에 DB 시계를 읽는다
  4. owner CAS 튜플을 SQL where 절에 전부 반복 — scope, token, attempt, claim operation, state revision. update count가 곧 답이다
  5. transition digest — 재시도가 같은 전이인지 구별
  6. Spring stereotype 없음 — 두 합성 루트가 dev.caskeleton.adapter를 스캔하므로

(2)번이 생긴 경위가 좋다:

store가 스레드에 활성 read-write 트랜잭션이 있는지 확인했는데, 그건 어떤 data source에서든 참이다. 차이는 data source가 둘인 애플리케이션에서 드러난다: 다른 쪽의 트랜잭션 안에서 발행된 mutation이 옛 검사를 통과하고, 이 store의 커넥션에서 트랜잭션 밖으로 돌고, 원자적이어야 했던 작업과 독립적으로 커밋됐다.

확정된 P1급 상태 결함 (05): inspect()claim()이 만료된 COMPLETED row를 동시에 다르게 해석(§59.1) · baseline outbox가 stale relay worker를 fence하지 못해 terminal state를 되돌릴 수 있음(§70) · durable operation의 lease 만료 후 stale owner 완료 가능(§71) · persistent byte quota가 admission에서 집행되지 않음(§78) · schema activation이 V2/V4를 current로 오인(§79·§88) · provider 호출 뒤 recipient projection write가 lease fencing 우회(§89) · RLS verifier가 "반드시 보호돼야 하는 table"의 부재를 성공으로 인정(§97) · database-per-tenant 전역 커넥션 예산이 새 pool 크기를 계산하지 않아 ceiling 초과(§98).

4.2 문서형 — persistence-mongo (497 파일 / main 351 / 22,924 LOC)

subsystem 전체가 배선돼 있지 않은데 그것을 켜는 flag는 startup 검사를 수행한다 (06 §49).

출하 default 조합이 첫 write에서 예외를 던진다 (06 §23). 세 사실이 겹친다:

  1. mongoTypeMetadataRegistry()비어 있는 registry를 기본 bean으로 등록한다 — "An empty registry so a deployment with no long-lived collection still starts."
  2. MongoTypeMetadataConfigurerPolicyAwareMongoTypeMapper모든 MappingMongoConverter에 무조건 설치한다.
  3. PolicyAwareMongoTypeMapper.writeType(...)이 등록되지 않은 타입에 IllegalStateException을 던진다.

즉 module을 켜기만 하고 type metadata를 등록하지 않은 배포는 시작은 하고 첫 write에서 실패한다. 그리고 같은 컴포넌트가 같은 질문에 세 가지로 답한다:

물음
미등록 타입의 정책은? CLASS_METADATA_ALLOWED (registry javadoc: "unregistered types keep Spring Data's default")
미등록 타입으로 type-restricted query를 만들면? Java class name을 _class predicate에 씀
미등록 타입을 write하면? 예외

읽기 경로와 쓰기 경로가 같은 정책 질문에 정반대로 답하고, 그중 어느 쪽도 registry가 스스로 문서화한 기본값과 일치하지 않는다.

high-water mark가 재전달된 이벤트를 삼킨다 (06 §67). MongoChangeStreamPipeline의 mark는 "투영이 완료된 위치"가 아니라 "본 적 있는 위치"다(이벤트를 받자마자 전진). resume 시 같은 pipeline 인스턴스를 다시 쓰므로 mark가 남는다.

실행 probe C: worker 하나, 평범한 failover. E(clusterTime 5.1)의 투영이 시작됐다가 취소되고, resume 후 재전달된 E는 pipeline이 삼킨다(mark가 이미 5.1). 그 다음 F가 투영되고 checkpoint가 token-6으로 저장되면서 저장 위치가 E를 지나친다.

PROBE-C projector started=2 completed=1
PROBE-C checkpoints saved=[token-6]
PROBE-C highWaterMark=6.1
PROBE-C state=RUNNING runbook=

E는 영구히 사라졌고, 구독은 RUNNING에 runbook은 비어 있고, caller의 Flux는 정상 완료한다.

왜 테스트가 못 잡았나가 특히 중요하다 — 세 테스트가 각각 절반씩 본다. 하나는 이벤트를 하나만 돌리고, 하나는 checkpoint store가 NoOpCheckpoints라 상호작용이 안 보이고, 하나는 첫 stream을 이벤트 전달 없이 실패시켜 mark가 설정되지 않는다. "본 적 있지만 완료되지 않은 위치"라는 제3의 상태가 어느 테스트에도 없다.

그 밖에: 프로파일의 TLS·타임아웃·풀·Stable API가 driver에 도달하지 않음(§75) · sharding admin gateway의 네 작업 중 셋은 어떤 입력으로도 완료 불가(§85) · index diff가 실제로 비교하는 것은 두 필드뿐(§57) · Flamingock lease로는 어떤 migration도 실행할 수 없는데 javadoc은 다르게 적음(§59).

4.3 messaging 신뢰성 저장소 (19 §7)

outbox/inbox 체인 전체가 만족되지 않는 @ConditionalOnBean 뒤에 있다.

@ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class})
public OutboxRelay outboxRelay(...)
@ConditionalOnBean(OutboxRelay.class)
public OutboxRelayWorker outboxRelayWorker(...)
@ConditionalOnBean(OutboxRelayWorker.class)
public MessagingOutboxRelayLifecycle outboxRelayLifecycle(...)
@ConditionalOnBean(InboxRepository.class)
public InboxCleanupJob inboxCleanupJob(...)

사슬의 뿌리인 OutboxRepository/InboxRepository의 유일한 구현(JdbcOutboxRepository, JdbcInboxRepository)을 어떤 자동설정도 만들지 않는다. 19 main 파일 / 2,818 LOC가 전부 조용히 비어 있다.

실패가 특히 조용하다. Spring은 조건부 bean이 조건을 만족하지 못하는 것을 오류로 보고하지 않는다. 즉 "outbox가 꺼져 있음"과 "outbox가 조립될 수 없음"이 런타임에서 구별되지 않는다. 같은 starter가 MessageCodecRegistry에는 @ConditionalOnMissingBean 기본 구현을 제공했다는 점이 이것을 결함으로 만든다.

마이그레이션 스트림을 적용하는 곳이 없고, 적용하려는 순간 버전이 충돌한다 (19 §7.2).

합성 루트의 Flyway 기본 위치는 PostgreSqlPersistenceConfig:115classpath:db/migration/postgresql이고, db/migration/messaging을 이름으로 부르는 main 코드가 저장소 전체에 0건이다. 그리고 두 leaf가 같은 리소스 디렉터리에 각자 번호를 매긴다:

messaging-inbox-jdbc-postgresql   V2__messaging_inbox.sql                (CREATE TABLE)
messaging-outbox-jdbc-postgresql  V1__messaging_outbox.sql
                                  V2__messaging_outbox_lease_fencing.sql (ALTER ×4)
                                  V3__messaging_admin_operation_journal.sql
                                  V4__messaging_outbox_canonical_metadata.sql

V2가 둘이다. 그 위치를 Flyway에 주는 순간 duplicate version으로 부팅이 실패한다. 각 leaf의 IT는 자기 jar 리소스만 보므로 재현하지 못한다. 원 구현 계획서는 분리된 위치 (db/migration/messaging-outbox, messaging-inbox)를 지정했었다.

설계 자체는 정확하다. V2__messaging_outbox_lease_fencing.sql의 헤더:

V1 recorded only lease_expires_at, so a claim said when it would end and nothing about who held it. ... relay A claims the row and calls the broker / the lease expires; relay B reclaims it, publishes, and records PUBLISHED / relay A finally times out and records AMBIGUOUS over the top. Making the lease longer than the publish timeout lowers the odds; it does not turn a GC pause, a scheduler stall ...

inbox는 (message_id, consumer_id) 복합 키로 소비자별 dedup을 트랜잭션 안에서 보장한다.

4.4 fileserver / objectstorage / cache-redis

fileserver (08, 119 파일). 6개 테이블. 가장 좋은 제약이 이것이다:

CONSTRAINT ck_fs_file_ready_is_complete CHECK (
    state <> 'READY'
    OR (content_key IS NOT NULL AND actual_size IS NOT NULL AND sha256 IS NOT NULL
        AND strong_etag IS NOT NULL AND published_at IS NOT NULL))

READY가 유일하게 공개 읽기 가능한 상태이므로, 완전하고 검증된 identity를 반드시 들고 있어야 한다 — 를 DB CHECK으로 강제한다. V2/V3/V4가 각각 실제 사고의 수정이다(staging 객체 주소지정 누락 / fenced cleanup lease 부재 / upload terminal state 부재).

발견: README가 "노출된 setting도 bean도 없다"고 적은 능력들에 production bean 8개가 있음 (§4·§39) · scriptable 콘텐츠 탐지가 접두사 시작에만 고정돼 BOM·NUL·주석으로 우회됨(§40).

objectstorage (09, 206 파일, sample-portfolio에만 출하). 발견: 직접 multipart의 마지막 part는 grant를 받을 수 없음(§38) · 서명된 grant의 endpoint 검증이 upload 경로에만 있음(§39) · APPLY를 켜는 설정은 있고 승인을 검증하는 bean은 없음(§49) · production 판정이 두 개의 리터럴 프로파일 이름에 걸림(§5).

cache-redis (10, 390 파일 / main 314 / 32,082 LOC). 발견: startup probe가 production에서 한 번도 실행되지 않음(§6) · SDK가 선언한 두 진입점에 구현이 없음(§15) · "build gate"라 불리는 catalog drift 검사가 어디에서도 실행되지 않음(§47) · 의미 어댑터 다섯이 CommandPolicyGuard를 지나지 않음(§64) · NOSCRIPT 복구가 다섯 벌로 구현돼 있고 넷은 스크립트 레지스트리를 지나지 않음(§56).


5. Failure and operational behavior

5.1 실패 분류 — 세 개의 계층

이 저장소에는 실패 어휘가 세 층으로 있고, 각각 다른 질문에 답한다.

(1) shared-contractCategory 10값 — 웹 표면이 응답 코드를 정하는 어휘. VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL. PERSISTENCE 카테고리는 없다.

OperationalErrorTest가 category × retryable 의미를 강하게 검증한다 — deterministic VALIDATION/AUTHZ/NOT_FOUND는 retryable=false, transient INTERNAL은 기본 true이되 deploy-time/ configuration/terminal 상태는 명시적 예외로 false. AUTH_KID_UNKNOWN은 key rotation 중 JWKS refresh 가능성 때문에 AUTH 중 유일한 retryable case로 pin돼 있다.

(2) persistence-jpaFailureCategory 16값 — 플랫폼 내부의 안정 예외 계층. api.error 23개 파일이고, 메시지를 서브클래스가 만들지 않는다 — 루트가 bounded 조각과 고정 라벨로 조립한다.

private static String describe(FailureCategory category, JpaFailureContext context) {
  // 등록된 operation name, 검증된 SQLSTATE, bounded identifier, enum, int, boolean 뿐.
  // row 데이터에서 유래할 수 있는 것이 하나도 없다.
}

(3) messaging의 3축FailureCategory + PublishCompletion(CONFIRMED/REJECTED/AMBIGUOUS)

  • TransmissionEvidence. §3.3.

웹 표면 SQLState 매트릭스(failure 패키지)가 (2)와 (1)을 잇는다. merge 규칙이 강경하다 — 서로 다른 contributor가 같은 exact SQLState를 등록하면 코드가 같아도 startup을 실패시킨다. last-writer-wins merge는 매핑 소유권 drift를 숨기기 때문이다.

미지의 SQLState는 Optional.empty()이고 translator는 DB_* 코드를 절대 지어내지 않는다 — 원본 예외를 web catch-all까지 전파시켜 detail 누설 없는 generic INTERNAL로 답한다.

5.2 관측 — 태그를 유한하게, 그리고 그 대가

JpaMetricTags다섯 개 태그뿐이다: persistence.unit, persistence.operation, persistence.query, outcome, failure.category. 없는 것이 요점이다 — 엔티티 id 없음, tenant id 없음, SQL 파라미터 없음, 예외 메시지 없음. 검증이 registry가 아니라 생성자에서 일어나고, sanitize가 아니라 reject다 — sanitize하면 caller가 unbounded 값을 계속 넘기고 절대 눈치채지 못한다.

shared-contractForbiddenMetricTags가 같은 규칙을 프로젝트 전체에 건다. request_id가 baggage에는 허용되지만 metric label에는 금지되는 비대칭이 테스트에서 의도적으로 pin돼 있다.

jpa.transaction.completion.unknown자기 카운터를 갖는 이유:

사람이 봐야 하는 유일한 결과다: 다른 모든 실패는 확실히 일어나지 않은 트랜잭션이고, 이건 일어났을 수도 있는 트랜잭션이다. 일반 실패율에 묻는 게 눈치채지 못하게 되는 방식이다.

그런데 messaging의 출하 publish 경로는 관측을 하나도 기록하지 않는다 (19 §5.1). 자동설정이 6인자 생성자를 골라 NO_OBSERVATION을 주입하고, MessagingMetrics·MessagingTracer· MessagingAuditSink·DefaultMessagingObservationConvention이 전부 main 참조 0이며, 등록되는 것은 협력자 MessagingRedactor·CardinalityGuard 둘뿐이다.

DefaultMessagePublisher의 해당 필드 javadoc이 그 상태를 정확히 예언한다:

MessagingObservation existed as a bean and no publish path called it, so the platform's own metrics described nothing. It is a constructor argument rather than an optional decorator because an unobserved publish path is how "the dashboards were empty during the incident" happens.

수정은 runtime-core에서 이뤄졌고, 조립이 그 수정을 되돌렸다. 그리고 §3.3의 "예외 메시지를 버린다"와 합쳐지면 진단 가능한 흔적이 남지 않는다.

5.3 시작 검증기 — 법칙과 그 예외

모듈 18에서 확립한 법칙: 시작 검증기가 도는지 여부는 그 능력에 .imports 자동설정 루트가 있는지와 정확히 일치한다.

app-bootstrap의 runtime 검증기 12종은 전부 배선돼 있다(RuntimeSafetyConfig @Bean 7 · SecretSourceConfig · MigrationStartupConfig). StartupFailures(75줄)가 구조화 실패 로그의 단일 발생원이며 종료 코드까지 규정한다(STARTUP_VALIDATION_FAILED=78 · MigrationFailed=70). main 157 파일에 고아 0 — 참조 0인 파일은 전부 @Configuration 루트 · logback 컴포넌트 · spring.factories 항목으로 설명된다.

JPA 쪽 startup 가드:

가드 하는 일
JpaDangerousConfigurationGuard open-in-view=true(local 편의 프로필 외), 스키마 변경 ddl-auto 거부
JpaDataSourceProfileValidator resolved DataSource를 열어서 product/version 확인
PostgreSqlVersionPolicy PostgreSQL 16/17/18만
PersistenceVendorProdSafetyValidator prod에서 H2 거부
HikariPoolConstraintValidator 풀 제약
NotificationSchemaActivation / FileserverSchemaActivation capability 스트림 승격 확인
PostgreSqlRuntimeRoleVerifier 런타임 롤이 DDL 못 함 (§4.1)

messaging 쪽은 6개 시작 검증기를 실제로 돌린다 (19 §9.1) — MessagingPrefixMigrationValidator (죽은 세 네임스페이스) · MessagingConfigurationKeyValidator(적법 키를 settings record에서 파생) · MessagingCredentialRequirementValidator(production 한정) · MessagingAdminDurabilityValidator · Kafka/Rabbit StartupProfileValidation 2종.

MessagingConfigurationKeyValidator의 근거가 특히 좋다:

A misspelt prefix is loud — the whole section is missing and someone notices. A misspelt key inside a section that does bind is the opposite: the entry appears, the platform starts, and the one setting the operator came to change is the only one that did not take. consumer.prefech: 64 is a throughput change that never happened.

법칙의 예외가 둘이다 — 루트가 있는데 검증기를 부르지 않는 경우:

  • adapter-inbound-webWebPlatformStartupValidator (14 §44.2)
  • grpc-spring-boot-starterGrpcPlatformStartupValidator (20 §3.1)

후자는 실행되지 않는 규칙이 13개다(transport·security 4 / executor 2 / methods 4 / channels 2 / advanced isolation 1). 클래스 javadoc이 13개를 고른 기준을 적는다:

Every rule here is a mistake whose runtime symptom is either silence or a misattributed failure: a unary method with no deadline hangs until the client's, an unbounded executor turns overload into unbounded latency, trust-all in production reports TLS while providing none, reflection in production publishes the schema, and a keyed method without a ledger accepts idempotency keys it cannot honour. None of them fails a smoke test.

5.4 admin plane — 가장 잘 조립된 게이트

messaging의 admin plane이 이 저장소에서 가장 잘 만들어진 조립이다 (19 §8.1).

@ConditionalOnProperty(prefix = "app.messaging.admin", name = "enabled", havingValue = "true")

matchIfMissing 없음 — 기본 꺼짐. 그리고 세 가지가 정확하다:

  1. DestructiveOperationGuard(false) — "an application runtime never holds an admin credential, so the guard refuses the operations that would need one."
  2. DestructiveMessagingAdmin은 의도적으로 bean이 아니다 — javadoc이 명시한다. 부재를 문서화한 것이 이 저장소에서 드물다.
  3. 비내구 journal + 시작 검증기 쌍:
throw new MessagingConfigurationException("ADMIN_JOURNAL_NOT_DURABLE",
    "the destructive-operation journal in use (" + journal.getClass().getSimpleName()
    + ") is not durable, and profiles " + active + " include a production profile; supply an "
    + "AdminOperationJournal bean backed by the shared database (JdbcAdminOperationJournal) so "
    + "one approval cannot be executed twice across replicas or across a restart");

메시지가 무엇을 공급해야 하는지 클래스 이름으로 말한다.

그런데 가드만 켜고 서비스는 켜지 않는다 (19 §8.2). DefaultMessagingAdminService· HmacApprovalVerifier·TopologyValidationRuntime이 main 참조 0이다. 부재 4건 중 하나만 문서화됐다.

실패 시나리오. 사고 대응 중 redrive를 실행하려고 app.messaging.admin.enabled=true로 켠다. 부팅은 성공하고 가드·journal·durability 검증기가 올라온다. 그런데 MessagingAdminService bean이 없으므로 호출할 대상이 없다. MessagingAdminDurabilityValidator의 javadoc이 경계한 시점 — "the gap only shows up during the incident the operation was run to resolve, which is the worst possible moment to discover it" — 과 정확히 같다.

5.5 gRPC 구현 층의 원자성 (20 §7)

grpc-policy·grpc-server의 동시성·경계를 읽고 6건을 확인했다. 여섯 중 넷이 같은 형태다 — Atomic* 타입을 쓰면서 원자적 연산을 하지 않는다.

GrpcAdmissionController.tryAdmit() — 조립되는 9개 bean 중 하나다.

int running = inFlight.get();
if (running < maxConcurrentCalls) {
  inFlight.incrementAndGet();          // 검사와 증가 사이가 열려 있다

경계에 있는 N개 스레드가 모두 같은 running을 읽고 모두 통과한다. 클래스 javadoc이 "under load"를 두 번 강조하는데, 부하 아래에서 지키라고 만든 경계가 부하 아래에서만 샌다. release()get() > 0 후 감소라 카운터가 −1이 될 수 있고, 그러면 경계가 영구적으로 느슨해진다. promoteFromQueue()inFlight를 경계와 대조하지도 않는다.

GrpcCredentialRotationManagerAtomicReferenceget()set() 홀더로만 쓴다(CAS 0건, synchronized 0건). 동시 회전 시 한 세대가 draining에 오르지 못한 채 사라진다. javadoc이 그 경합을 이미 알고 있다 — "the usual reason for one is two rotators racing" — 면서 닫는 연산을 쓰지 않았다.

그리고 이것은 messaging이 이미 고친 결함이다. CredentialRotationContractTest:

resolve was get → fetch → put → clear with no synchronization. Two callers rotating the same credential both read the same old runtime and both fetched a replacement: one replacement was dropped from the map without ever being cleared — a secret left in memory that nothing owns.

GrpcSerializedStreamWriterDROP_OLDEST — 동기화는 정확한데(9곳) 바이트 회계가 틀렸다.

queuedBytes = Math.max(0L, queuedBytes - nextBytes);   // nextBytes = 들어오는 메시지 크기

버려지는 것은 dropped인데 빼는 값은 새로 들어오는 메시지의 크기다. GrpcStreamEnvelope가 크기를 담지 않으므로 알 방법이 애초에 없다. 그리고 테스트는 sizer가 상수 8L이라 모든 메시지 크기가 같아 결함이 보이지 않는 구성이다.

나머지 둘: GrpcStreamAdmission(같은 TOCTOU + perCaller 맵 무제한) · GrpcOutcomeReplay(제거· TTL·개수 상한 없는 인메모리 저장소) · GrpcCompletionReconciler(요청 경로에서 동기화 없는 ArrayList).

정확한 참조 구현이 전부 같은 가족 안에 있다GrpcRetryBudget은 CAS 루프, GrpcCancellationCoordinator는 5개 메서드 전부 synchronized, GrpcClientMessageDeduplicatorendSession으로 두 맵을 정리한다. 지식의 부재가 아니라 적용의 불균일이다.

사이클 2 전수 통독이 더한 것 — 같은 형태가 둘 더 있고, 그중 하나는 더 무겁다.

GrpcChannelRuntimeRegistry.rotate 는 같은 파일의 installcompareAndSet 을 쓰는데도 조건 없는 set 을 쓴다. 두 회전이 겹치면 덮인 대체본이 draining 목록에 오르지 못해 배수도 회수도 되지 않는다 — 자격증명 쪽과 같은 형태다.

GrpcChannelRuntime.finishUnaryCall·closeStreamget() > 0 후 별도 감소인데, 이 리프에서는 결과가 구체적이다. quiescent()정확히 0 을 요구하므로 카운터가 음수가 되면 그 세대는 영원히 조용해지지 않고 retireQuiescent 가 결코 제거하지 않는다. 회전이 반복될수록 배수 목록이 자란다.

그리고 자격증명 회전의 더 무거운 절반이 통독에서 나왔다.

public void completeDrain() {
  State observed = state.get();
  state.set(new State(observed.current(), null, null));
}

읽기와 쓰기 사이에 회전이 일어나면 그 회전이 활성화한 세대가 지워지고 이전 세대가 다시 현재가 된다. 방금 교체된 자격 자재가 되살아난다는 뜻이고, 이 클래스의 존재 이유가 정확히 그 교체다. 수정은 updateAndGet 한 줄이다. (grpc/grpc-policy §17.2, grpc/grpc-client §17.1·§17.2)


6. Tests and verification coverage

6.1 실행한 것

대상 결과
messaging 25 leaf :test 전량 BUILD SUCCESSFUL 2m27s · classes=110 tests=851 failures=0 skipped=0
gRPC 18 leaf :test 전량 BUILD SUCCESSFUL 1m12s · classes=71 tests=579 failures=0 skipped=0
gRPC 증거 레인 3종 (inProcess·netty·fault) BUILD SUCCESSFUL · 7+9+9 · Netty는 실제 소켓
:adapter:inbound:graphql:test classes=186 tests=1603 failures=0 skipped=0
:adapter:inbound:websocket:test classes=91 tests=720 failures=0 skipped=0
:adapter:inbound:grpc:test + qualification classes=8 tests=48 failures=0 skipped=0
:app-bootstrap:test tests=1016 failures=1 — 유일 실패는 환경 원인(jq 부재), 1,015 통과

6.2 실행하지 않은 것과 그 이유

컨테이너·브로커·DB·별도 서버가 필요한 레인:

  • messagingCertificationTest — Docker 필수. 의도적으로 skip 가드가 없다(§6.4).
  • grpcPerformanceTest — 공유 러너 측정은 baseline이 될 수 없다.
  • JPA의 jpaPlatformContractTest·MigrationTest·FailureTest·QueryPlanTest·SecurityTest· PoolContractTest + readiness task 15종 — 실 PostgreSQL 필요.
  • mongo/redis/fileserver/objectstorage/httpclient의 Testcontainers 계열.
  • websocket 커스텀 레인 4종(nginx·brokerRelay·advanced·jetty).

이들이 통과한다는 것은 문서와 커밋된 manifest의 주장이고, 그중 messaging 인증만 CI가 강제한다(§6.4).

사이클 2 정정 — JPA 태그 레인 다섯은 실제로 돌았다. 위 목록은 사이클 1 시점이다. 사이클 2는 Docker가 있는 환경에서 jpaPlatformContractTest·MigrationTest·FailureTest·QueryPlanTest· SecurityTest--rerun-tasks로 실행했고 51 클래스 244 tests · 0 skipped · 0 failures · BUILD SUCCESSFUL in 3m 10s였다 (EVD-115, §A05 §135·§139). 리비전은 a24ece9c이고 HEAD가 아니다. 아직 돌지 않은 것은 compose 스택, readiness task 중 실패한 2건 (postgresqlMigrationIntegrationTest 단독 실행이 BUILD FAILED — EVD-117·EVD-118), 그리고 messaging 인증 레인이다. §13.2와 §14.1이 이 정정의 정본이다.

6.3 fail-closed 레인 규약

JPA 레인이 이 저장소의 표준을 세웠다.

failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }

failOnNoDiscoveredTests가 여기서는 평소보다 중요하다: 아무것도 발견하지 못한 선택된 레인은 성공을 보고하고, 조용히 돌기를 멈춘 계약 suite는 통과하는 것과 구별되지 않는다.

Docker 부재도 skip이 아니라 에러다:

throw new IllegalStateException(
    "Docker is required for the PostgreSQL contract suite and is not available; this lane"
        + " fails closed rather than skipping, because a skipped contract reports success"
        + " for a database nobody tested");

PostgreSqlVersion.parseSelection("")에러다 — "빈 PostgreSQL 매트릭스 선택은 빈 실행이 아니라 에러다."

6.4 완전히 닫힌 게이트 하나 — messaging 인증 체인

이 저장소에서 유일하게 네 층을 모두 갖춘 게이트다 (19 §2.3).

레인이 파일을 쓴다      messagingCertificationTest → build/.../broker-certification-evidence.jsonl
Gradle이 대조한다        verifyMessagingCertificationEvidence: ran ≠ shipped → GradleException
                        (gitCommit·observedAt은 제거 후 비교, upToDateWhen{false})
CI가 게이트를 돌린다     messaging-certification.yml, src/messaging/** PR마다
가드를 일부러 안 단다    "a lane that skipped would report success for a broker nobody started"

대조가 양방향이다 — 손으로 추가한 줄(shipped - ran)도, 기록되지 않은 실행 결과 (ran - shipped)도 실패시킨다.

그리고 커버리지를 손으로 유지하지 않는다. CertifiedEvidence.knownGaps(adapter)all() covered파생하고, 빠진 시나리오를 이름과 이유까지 붙여 단언한다:

void aScenarioWithNoLineInTheManifestIsAGapRatherThanAnAbsence() {
  assertThat(CertifiedEvidence.knownGaps("messaging-kafka"))
      .as("a Kafka producer buffers before it learns a connection exists, so this stays unproven")
      .contains(NetworkFaultScenario.CONNECTION_REFUSED);
  assertThat(CertifiedEvidence.knownGaps("messaging-rabbit"))
      .as("no lane runs a fault scenario against RabbitMQ, so every scenario is a gap")
      .containsExactlyElementsOf(NetworkFaultScenario.all());
}

등급도 boolean이 아니라 증거에서 파생된다:

public boolean hasLiveBrokerCertification() {
  return BrokerFailureMatrix.from(CertifiedEvidence.recorded()).hasLiveBrokerCoverage(adapter);
}

Read from the evidence rather than declared. As a field it was a boolean an author set next to the tier, and RabbitMQ carried true while no fault scenario had ever been executed against it.

6.5 evidence manifest — JPA의 R1/R2 분리

gradle/jpa-evidence.gradle(917줄)이 active card 11개의 producer를 실행하고 JUnit XML에서 exact selector와 executed/skipped/failure/error 수를 읽는다. 각 manifest는 source revision/dirty digest, prerequisite manifest ID, PostgreSQL image digest, 드라이버/Hibernate/Flyway 버전을 담고 canonical JSON SHA-256 이름으로 생성된다.

후보 검증이 통과해도 attainedReadiness=R1을 유지한다. 각 manifest가 candidate profile, dirty source, 아직 R2가 아닌 prerequisite를 readinessBlockers에 보존해서 후보 통과를 R2로 오인할 수 없다.

진짜 게이트는 별도이고 clean revision + CI provenance + immutable image digest를 요구한다. 로컬 dirty worktree에서 worktree-is-dirty blocker로 실패하는 것이 정식 동작이다.

6.6 게이트가 통과하면서 아무것도 증명하지 않는 경우 — 14건

모듈 § 게이트가 실제로 하는 일
05 jpa 52 blocking release gate collection-fetch-pagination이 실제 위험을 증명하지 않음
05 jpa 132 선택된 base card jpa-flyway-migration의 producer가 현재 리비전에서 실패
05 jpa 133 base card 3개의 evidence tag가 production code 없는 fixture로 충족됨
05 jpa 125 nightly workflow가 광고하는 세 가지 중 하나를 lane이 관측하지 않음
06 mongo 94 커버리지 gate 둘이 나란히 있고 하나는 발화할 수 없음
06 mongo 95 release gate가 실제로 차단하는 것은 hermetic test 3개, mongo용 CI workflow는 없음
10 redis 47 "build gate"라 불리는 catalog drift 검사가 어디에서도 실행되지 않음
13 notification 9 다중 타깃 검증을 확인한다는 테스트가 hasMessageContaining("strategy")다른 가드에 걸려 통과
14 web 48.1 크로스 스택 게이트가 검증하는 조립은 픽스처의 조립
18 bootstrap 4.1c 조건부 전송 게이트가 별도 레인에서만 돌아 빨간 채로 방치된 이력이 javadoc에 기록됨
19 messaging 3.7 everyRecordedScenarioIsALineTheCertificationLaneWrote같은 파일을 두 경로로 비교
19 messaging 9.3 문서 계약 테스트 단언 8개의 커버리지 밖에 발견된 문서 드리프트 3건이 전부
20 grpc 3.2 릴리스 게이트가 읽는 증거를 아무도 생산하지 않음 (Gradle 태스크 0 · CI 0)
20 grpc 3.3 증거 등급 모델(25개 테스트)이 check 밖 · CI 밖

JPA jpaPlatformPoolContractTest의 이름 변경 기록이 이 유형의 정석적 수정이다:

jpaPlatformPerformanceTest였고, pool과 REQUIRES_NEW 압력을 certify한다고 기술됐고, 나타나는 모든 곳에서 off가 기본인 boolean 뒤에 게이트되어 있었다. 그래서 릴리스 게이트가 유일한 threshold assertion이 "threshold를 assert하지 않고 있다"인 레인에 의존했고, "certified"는 어떤 latency나 throughput bound도 무언가와 비교된 적 없는 실행을 기술했다.

프로퍼티는 사라졌다; 그 이름은 여기 일부러 반복하지 않는데, 주석 속의 이름이 다음 사람이 설정해 보려는 바로 그것이기 때문이다.


7. 이 저장소에서 반복된 네 가지 형태

62개 leaf를 읽고 나서 남는 것이다. 개별 결함보다 이쪽이 값이 크다. 원본은 99 §2–§5.

7.1 형태 A — 판정하는 코드는 있고, 부르는 코드가 없다

20개 모듈 중 13개에 있다. 저장소 자신이 이 형태에 이름을 세 번 붙였다:

StartupProfileValidation: "The Kafka, RabbitMQ and security validators were all beans and none of them was injected anywhere: the context published a validator per broker and validated nothing."

DefaultMessagePublisher: "the admission controller, access policy, runtime registry and observation existed as beans that no publish ever called."

RegisteredMessageCodecs: "MessageCodecRegistry was an interface with no implementation anywhere — declared, consumed by DefaultMessagePublisher, and satisfiable by nothing."

모듈 § 무엇이 조립되지 않았나 규모
05 jpa 23·69·78·91·116 completion-evidence capability · runtime-role verification · byte quota · V8 atomic admin claim · vendor selector fail-fast
06 mongo 49 subsystem 전체 미배선인데 켜는 flag는 startup 검사를 수행 497 파일 leaf
06 mongo 32 문서가 지목한 deadline 메커니즘의 production 호출자 0
07 identifier 3 모듈의 존재 논거인 UuidCodec에 production 소비자 0 10 파일 leaf
09 objectstorage 49 APPLY를 켜는 설정은 있고 승인을 검증하는 bean은 없음
10 redis 6·15 startup probe가 production에서 미실행 · SDK 진입점 둘에 구현 없음
13 notification 17.1 "Every reveal is auditable"의 세 필드를 읽는 코드 0
14 web 8.1 스캔 제외 패키지의 여섯 컴포넌트를 두 자동설정 어느 것도 소유 안 함 RFC 9457 23파일
14 web 16.1·20.1 용량 보호 41파일 · 멱등/durable 표면이 픽스처에서만 조립 41파일
14 web 32.2 forwarded 헤더 Java 정책 421 LOC 미배선 421 LOC
14 web 44.2 WebPlatformStartupValidator 미실행
16 graphql 8.1·16.1·24.1 스키마 조립·해시 사슬 · 파서 한계 미설치 · 커서 서명 키를 요구하는데 서명하는 코드가 없음
17 websocket 4.1 세 안전 장치 호출자 0 약 90파일
19 messaging 3.4·3.5·4.3·4.5·5.1·5.2·7.1·8.2 capability 거부 1/12 · 트랜잭션 validator · 스키마 호환성 · cloudevents · 관측 · ACL 자기점검 · outbox 체인 · admin 서비스 2,818 LOC 등
20 grpc 3.1·3.4 시작 검증기 13규칙 · 인터셉터 체인/어댑터 15종

원인이 세 갈래로 갈린다. 세 경우의 조치가 다르다.

(a) 자동설정 루트 자체가 없다. §5.3의 법칙이 여기서 나온다.

(b) 루트는 있는데 통제를 소유하지 않는다. 모듈 14 §8.1(§3.1)과 모듈 20 §3.1이 이 경우다. ProblemCatalog는 빈이고 그것을 쓰는 @RestControllerAdvice는 빈이 아니다. dependency 쪽만 소유되고 control 쪽은 소유되지 않았다.

(c) 조건이 영원히 만족되지 않는다. 모듈 19 §7.1(§4.3)이 유일한 순수 사례다. Spring은 이것을 오류로 보고하지 않으므로 "꺼져 있음"과 "조립될 수 없음"이 런타임에서 구별되지 않는다.

7.2 형태 B — 게이트가 통과하면서 아무것도 증명하지 않는다

§6.6에 14건의 표가 있다. 판정 기준 셋은 전부 저장소 자신의 문장에서 나왔다:

  1. "class existence is not composition evidence."ConditionalTransportCompositionContractTest
  2. "A gate that is red in a lane nobody runs locally is a gate that reports whatever the last person to run it saw." — 같은 테스트
  3. "the matrix and the support document agreed with each other and with nothing that had executed."CompatibilityMatrix. 문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다.

7.3 형태 C — 중복 장치에서 조립된 쪽이 약한 쪽이다

모듈 § 조립된 장치 조립되지 않은 / 우회된 장치
06 mongo 58 규칙 없는 TTL 선언 규칙을 가진 TTL 선언 (아무도 안 씀)
10 redis 56 NOSCRIPT 복구 5벌 중 4벌이 레지스트리를 안 지남 레지스트리를 지나는 1벌
10 redis 64 의미 어댑터 5종이 CommandPolicyGuard를 안 지남 CommandPolicyGuard
13 notification 25.1 클라이언트 제공 Web Push 엔드포인트가 쓰는 약한 검사의 private 사본 SSRF 가드
13 notification 17.2 프로덕션이 타는 오버로드 Thymeleaf 예외 메시지 삭제 가드가 있는 오버로드
14 web 24.1 배선된 캐시 필터의 no-store 조건부 읽기 경로(무력화) · 조정하려던 패키지(참조 0)
14 web 32.1 클라이언트가 요청 식별자를 고르게 하는 다른 배선 필터 "고를 수 없다"는 정책
14 web 32.2 Nginx 설정의 forwarded 헤더 신뢰 판정 Java 정책 421 LOC
19 messaging 5.3 인가 거부를 CONFIGURATION으로 분류 DestinationAccessValidator(MessageAuthorizationException)
19 messaging 5.4 CredentialRuntimeRegistry(동시성 계약 테스트 있음) CredentialRotationPlan(참조 0)
19 messaging 6.5 둘 다 조립됨kafkaSeamProducer · messagingKafkaProducer — (MSG-015)
05 jpa §17 P1 SpringPolicyTransactionPort + TransactionRetryBackoff FullTransactionRetryCoordinator(주입처 0)
05 jpa §12.5 audit/AuditableEntity auditing/AuditMetadata(완성됐고 조립 안 됨)

방향에 규칙이 있다. 13건 중 11건에서 조립된 쪽이 더 약하거나 덜 정확하다. 이유는 §1.4가 설명한다 — 잘 만들어진 정책 객체는 프레임워크 표면 밖(95.5%)에 있고, 실제로 요청 경로에 놓이는 것은 프레임워크 표면 안(4.5%)에서 손으로 배선한 것이다.

변종: 원자적 타입을 쓰면서 원자적으로 하지 않는다 (20 §7, §5.5). 두 장치가 아니라 같은 문제의 옳은 해법과 틀린 해법이 한 가족 안에 공존한다.

7.4 형태 D — 문서 드리프트, 그리고 그 방향

모듈 § 드리프트 방향
06 mongo 4 README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않음 과대
07 identifier 5 문서는 UUIDv7이라 말하고 생성되는 것은 v4 과대
07 identifier 6·7·8 CLAUDE.md 의존성 서술 세 항목 모두 틀림 · README 사실 오류 3건 · 대는 두 가드 중 하나가 저장소에 없음 과대
08 fileserver 4 README가 "노출된 setting도 bean도 없다"고 적은 능력들에 production bean 8개 과소
10 redis 5 README readiness 표와 build.gradle 주석이 실제 소스와 어긋남
12 messaging-adapter 4 README의 jackson-databind 부재 주장이 현재 상태와 어긋남
14 web 40.2 리액티브 활성화 조건에 대한 build.gradle 서술이 코드와 다름
16 graphql 20.1 등급표 13행 중 한 행만 관측과 어긋나고 방향이 표가 금지한 "높게 표현" 쪽 과대
19 messaging 6.3 지원 매트릭스가 Kafka deduplicatedPublishO로 적고 코드는 false 과대
19 messaging 6.4 지원 매트릭스가 "모든 leaf build-only" — 실제 18/25 출하 과대
19 messaging 6.2 Rabbit이 선택 불가라는 사실이 운영 문서에 없음 과소
20 grpc 3.6 CLAUDE.md의 grpc-discovery 행이 UDS resolver를 빠뜨림 과소
20 grpc 2.6 지원 매트릭스가 "Not released / build-only"를 정확히 공시 일치
05 jpa §17 P3 docs/** 5개 문서가 "exactly 19 leaf"라고 적음 (실제 62)

과대 진술이 과소보다 위험하다. 20개 문서에서 나온 P1급 문서 드리프트는 단 하나이고(19 §6.3), 그것이 과대 방향이다. 이유는 그 문서를 읽은 팀이 자기 코드를 생략하기 때문이다:

KafkaMessagingTransport javadoc: "Declaring it true means PublishDeduplication is accepted and silently does nothing — the caller believes the broker is deduplicating and skips the idempotency it would otherwise build."

문서 계약 테스트의 경계가 드리프트 위치를 예측한다 (19 §9.3). messaging에는 doc rot를 막기 위한 MessagingDocumentationContractTest가 있고 단언 8개를 갖는다. 그 단언이 붙드는 것(등급 이름 · Kafka 버전 문자열 · 존재하지 않는 두 enum 상수)은 전부 정확하고, 붙들지 않는 것(capability 표 60칸 · runtime membership 문장 · 브로커 등급표의 "제한" 칸)에 발견된 드리프트 3건이 전부 있다.

우연이 아니다. 그 테스트의 javadoc이 좁은 단언을 고른 이유까지 옳게 적는다 — "Asserting on wording would make every edit a test failure and the check would be deleted." 결함은 좁게 고른 것이 아니라 그 경계가 어디인지가 문서에도 테스트에도 적혀 있지 않다는 점이다.

7.5 공시 스펙트럼 — 자기 미완성을 얼마나 말했는가

같은 저장소 안에서 모듈마다 공시 정도가 다르고, 그 차이가 P1 개수와 거의 정확히 반비례한다.

등급 모듈 공시 방식 P1
완전 공시 20 grpc 지원 매트릭스가 "Not released … build-only"와 미해결 게이트 입력 2건을 스스로 나열 0
완전 공시 16 graphql CLAUDE.md가 4등급을 정의하고 13행 중 일곱을 스스로 강등 0
완전 공시 18 bootstrap 조건부 전송 게이트가 자기 이력(빨간 채 방치)을 javadoc에 기록 0
부분 공시 17 websocket CLAUDE.mdstomp 8파일만 서술 — 90개 플랫폼 파일은 덮지 않음 0(하향)
부분 공시 19 messaging 가족 CLAUDE.md가 MSG-015를 P0 미해결로 명시하고 자기 문장의 오류까지 정정 — 그러나 운영 문서는 미수정 1
역방향 공시 14 web README가 반대를 서술 6

그러나 공시가 조립을 대체하지 않는다. 모듈 20은 P1 0이면서 P2 10건이다.

7.6 학습 전이 — messaging → grpc

같은 문제를 두 번 푼 흔적이 있고, 두 번째가 첫 번째에서 무엇을 가져왔는지 추적할 수 있다.

messaging이 배운 것 grpc로 옮겨졌나 근거
애플리케이션이 플랫폼에 도달하는 bridge를 먼저 정하라 (MSG-015) adapter-inbound-grpc가 레지스트리상 이 가족을 볼 수 없음 (20 §2.3)
framework-free 계약 leaf ✔ 더 강해짐 grpc-core-api/build.gradledependencies {}가 비어 있음
전송 선택은 classpath 사고가 아니라 속성 두 가족 모두 단일 master switch + fail-closed
등급은 boolean이 아니라 증거에서 파생 GrpcReleaseEvidence 5성분 전부 호출자 제공. 유일 생성 지점이 자기 테스트
게이트를 CI가 돌려야 한다 Gradle 태스크 0 · 28개 워크플로 중 grpc 언급 0
시작 검증기를 조립에 연결하라 GrpcPlatformStartupValidator main 참조 0

설계는 옮겨졌고 강제 배선은 옮겨지지 않았다. 옮겨진 셋은 전부 레지스트리와 build.gradle로 표현되는 것이고, 안 옮겨진 셋은 전부 Gradle 태스크와 CI YAML로 표현되는 것이다. 전자는 문서에 적으면 다음 사람이 따라 하고, 후자는 적어도 따라 하지 않는다.


8. Confirmed problems

8.1 P1 — 지금 출하되는 아티팩트에서 틀린 동작

# 발견 위치
1 지원 매트릭스가 Kafka deduplicatedPublishO로 적고 코드는 false. capability 표 60칸 중 유일한 불일치이고, 하필 12개 중 유일하게 실제 거부를 발생시키는 플래그다 19 §6.3
2 RFC 9457 계약 23파일이 출하 애플리케이션에 등록되지 않음 (§3.1) 14 §8.1
3 플랫폼 요청 컨텍스트가 서블릿에 생산자가 없고 리액티브에 익명 액터로 고정 14 §12.1
4 용량 보호 계층 41파일이 자기 테스트 픽스처 안에서만 실행 14 §16.1
5 멱등 실행 계층·durable-operation 표면이 픽스처에서만 조립 14 §20.1
6 리액티브 절반 29파일이 어떤 출하 배포에서도 활성화 불가 14 §40.1
7 크로스 스택 게이트가 픽스처의 조립을 검증 14 §48.1
8 mongo 출하 default 조합이 첫 write에서 예외 (§4.2) 06 §23
9 mongo high-water mark가 재전달 이벤트를 삼켜 변경이 영구 소실 (§4.2) 06 §67
10 mongo 프로파일의 TLS·타임아웃·풀·Stable API가 driver에 도달하지 않음 06 §75
11 cause.getMessage() 때문에 PII-safe logging 계약이 성립하지 않음 04 §4
12 notification consumer가 diagnostic failure를 authoritative failure로 바꿀 수 있음 04 §5
13 notification admin atomic claim contract가 service에서 사용되지 않음 03 §12.4
14 UUIDv7 계약과 실제 validation 불일치 01 §11
15 jpa: 상태·펜싱·쿼터 계열 다수 (§59.1·69·70·71·78·79·88·89·97·98·107·108·132) 05

사이클 2에서 P1 하나가 철회되었다. 사이클 1의 11번 — "영구 TLS 실패가 재시도 가능한 CONNECT로 분류" — 은 제품 결함이 아니라 테스트 픽스처의 듀얼스택 호스트명이 원인이었다. 위 표는 그 철회를 반영해 15건이며, 근거는 §14와 EVD-332다.

#11의 재현이 특히 구체적이다 (04 §4.3). support source와 README가 "logger method가 body/recipient/payload를 받지 않기 때문에 PII가 log에 닿지 않는다"고 주장하는데, logFailurecause.getClass().getSimpleName() + ": " + cause.getMessage()를 그대로 넣는다. 실제 probe:

입력: RuntimeException("provider rejected recipient secret@gmail.com body=secret-body-content")
출력 WARN: error="RuntimeException: provider rejected recipient secret@gmail.com body=secret-body-content"

기존 테스트가 통과한 이유는 fixture의 exception이 단순히 "connection refused"라서다 — payload object가 직접 argument로 전달되지 않는다는 것만 확인할 뿐 exception message를 통한 leakage를 검사하지 않는다. app-bootstrap의 LogMaskingPatterns도 arbitrary email/free-form body PII를 제거하는 규칙이 없고, README 스스로 regex masking을 보증이 아니라 defence-in-depth라고 적는다.

8.2 P2 — 명확한 실패 시나리오를 가진 실질적 공백

형태별로 §7에 정리했다. 가장 무거운 여덟:

  1. messaging 관측 경로 전체가 no-op — 대시보드·감사·트레이스 모두 없음 (19 §5.1, §5.2)
  2. messaging outbox/inbox 2,818 LOC이 만족 불가 조건 뒤 (19 §7.1, §4.3)
  3. messaging 마이그레이션 스트림 미적용 + 적용 시 V2 중복 실패 (19 §7.2, §4.3)
  4. capability 12개 중 거부하는 것 1개 — javadoc의 "fails loudly" 미성립 (19 §3.4)
  5. gRPC 릴리스 게이트가 자기 증거를 읽지 않음 — messaging이 이미 닫은 모양의 재발 (20 §3.2)
  6. gRPC 구현 층의 원자성 결함 4건GrpcAdmissionController(조립되는 bean) 포함 (20 §7)
  7. JPA 재시도 구현이 둘이고 하나는 아무도 호출하지 않음 (05 §17 P1) — 실제로 도는 재시도는 COMMAND_SERIALIZABLE_REPLAY_SAFE 정책에만 적용되고, DefaultJpaRetryPolicy의 6단계 순서· IrreversibleSideEffectContext 확인·RetryBudget elapsed 상한이 전부 아무도 호출하지 않는 경로에 있다. support-matrix.md는 "Full-transaction retry | Stable"이라고 선언한다.
  8. JPA 플랫폼 capability 대부분에 production 소비자가 없다 (05 §17 P8) — springdata / hibernate.* / postgresql.{write,lock,json,array,range,copy} / cache / envers / querydsl 전부. 결정적 증거는 같은 leaf 안의 두 스토어(fileserver 25파일, notification 53파일, 합 350KB)가 JPA 플랫폼 타입을 하나도 import하지 않고 같은 문제(큐 클레임·정렬·충돌 판정)를 각자 다시 만들었다는 것이다.

사이클 2 전수 통독이 더한 P2 — 다섯. 전부 미배선 블록 안쪽이므로 §8.3 의 등급 완화가 함께 적용되고, "조립되는 즉시 성립" 이라는 조건이 붙는다.

  1. gRPC 연산 원장의 insert-first 주장이 Spring Data 의 save 계약과 어긋난다 — 두 번째 청구가 유니크 위반을 일으키지 않고 커밋된 결과를 덮어쓴다. 테스트 이중이 INSERT 를 흉내 내 그 차이를 가린다 (grpc/grpc-operation-ledger-jpa §17.1)
  2. GrpcPlatformStartupValidator 가 시작 시 실행되지 않는다 — 그 검증기가 유일한 소비자인 설정 키 넷(transport·tls-enabled·trust-all-certificates·operation-ledger-enabled)이 아무것도 게이트하지 않는다 (grpc/grpc-spring-boot-starter §17.1)
  3. GrpcCredentialRotationManager.completeDrain() 이 진행 중인 회전을 되돌린다 — 방금 교체된 자격 자재가 다시 현재가 된다 (grpc/grpc-policy §17.2, §5.5)
  4. NATS 가 deduplicatedPublish 를 무조건 참으로 선언한다 — 프로파일에 중복 제거 창이 없으면 Nats-Msg-Id 를 보내지 않으므로 서버가 중복을 제거하지 않는다. 이 플래그는 부재가 예외를 만드는 유일한 능력이라 창 없는 목적지가 그 가드를 통과한다 (messaging/messaging-nats-experimental §17.1)
  5. Kafka 트랜잭션 검증기가 감싸이지 않았고, 능력은 무조건 참을 답한다 — 검증기는 messaging-kafka 가 소유하고 그것을 시작 시 부르는 배선은 스타터가 소유한다. 어느 쪽 문서도 혼자서는 이 사실을 말할 수 없다 (messaging/messaging-kafka §17.2, messaging/messaging-spring-boot-starter §17.2)

그리고 진단 마스킹의 IPv4 전용 가정 은 형태가 달라 따로 둔다 — GrpcDiagnosticsRedactor.maskAddress 가 IPv4 가 아닌 입력을 그대로 돌려주고, 스냅숏의 "마스킹되지 않은 주소" 검사가 결과==입력을 통과로 읽으므로 IPv6·호스트 이름·유닉스 소켓 경로가 전부 통과한다 (grpc/grpc-advanced-diagnostics §17.1). 사이클 2 가 철회한 P1 의 원인과 같은 계열의 가정이다.

8.3 심각도가 등급 때문에 낮아진 것

runtime_memberships: []인 27 leaf(729 파일 / main 479)의 미조립은 채택 시점 부채로 기록했다.

  • adapter-inbound-websocket — 약 90개 플랫폼 파일에 조립 지점 없음, 세 안전 장치 호출자 0 (17 §4.1, P1→P2 하향)
  • gRPC 가족 18 leaf 전체 — P1 0건인 이유가 이것이다 (20 §5). 다만 §7.1의 GrpcAdmissionController는 조립되는 9개 bean 중 하나이므로 채택 시 가장 먼저 청구된다.

9. Reusable criteria and rules

이 20편을 쓰는 데 실제로 쓴 규칙이다. 전부 저장소 자신의 문장에서 나왔거나 반복 관찰에서 굳었다. 원본은 99 §9.

  1. runtime_memberships를 먼저 읽는다. build-only leaf의 미조립은 오늘의 사고가 아니라 채택 시점의 부채다. (17 §26.6 · 19 §1.1 · 20 §1.1)
  2. 클래스가 로드된다는 것은 조립 증거가 아니다. (18 §4.1c)
  3. @Bean이 있다는 것도 조립 증거가 아니다. 주입처를 확인한다. (19 §3.5 · 20 §3.1 · 14 §8.1)
  4. @ConditionalOnBean은 만족 가능성을 확인해야 한다. 뿌리 타입의 구현이 저장소 안에 있고 그것을 만드는 자동설정이 있는지. (19 §7.1)
  5. 문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다. 실행된 것과 대조한다. (19 §6.6)
  6. 아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다. check 연결과 CI 워크플로를 확인한다. (18 §4.1c · 20 §3.3)
  7. 문서 계약 테스트가 있으면 그 단언 목록을 먼저 읽는다. 드리프트는 그 경계 밖에 있다. (19 §9.3)
  8. 중복 장치를 찾으면 조립된 쪽이 어느 쪽인지 확인한다. 13건 중 11건이 약한 쪽이었다. (§7.3)
  9. 과대 진술 문서를 과소보다 먼저 고친다. 과대는 팀이 자기 안전장치를 생략하게 만든다. (19 §6.3)
  10. 조립하는 쪽을 먼저 읽는다. 정정 네 건 전부가 이 순서를 어겨서 생겼다. (§13)
  11. Atomic* 타입의 존재는 원자성의 증거가 아니다. get()으로 비교하고 별도로 increment/set 하는 것은 AtomicInteger를 쓴 check-then-act다. (20 §7)
  12. 빠뜨림이 통과가 되는 게이트는 게이트가 아니다. JpaModuleBoundaryTest의 카탈로그 정확한 동등성, PersistenceEntityScanCoverageTest, failOnNoDiscoveredTests = true, verifyDocumentedLeafCount의 트리 walk가 전부 같은 교훈이다. (05 §1·§16)
  13. 모르는 것은 일급 결과여야 한다. RetryDisposition.RECONCILE, TransactionResult.Indeterminate, WriteDisposition.UNDETERMINED, PublishCompletion.AMBIGUOUS, ReplicaLagMonitor.replayedThrough(): Optional. "모른다"를 성공이나 실패로 접으면 그 정보가 영원히 사라진다. (05 §16 · 19 §3.2)
  14. 위험한 상태는 타입이 표현할 수 없게 만든다. completionUnknown && retryable 거부, PublishEvidence의 compact constructor. 리뷰 규칙이 아니라 생성자다. (05 §2.2 · 19 §3.2)
  15. 이름은 값이 아니라 registry key다. 쿼리·큐·업서트·JSON path·COPY 이름 전부. 이름이 statement를 선택하지, statement의 일부가 되지 않는다. (05 §16)
  16. path/identifier는 등록, value는 바인딩. 파라미터로 바인딩할 수 없는 것만 registry로 고정한다. (05 §7.5)
  17. 시간은 DB에서, 그리고 락 이후에 읽는다. (05 §10)
  18. 로컬 환경이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다. char(64)fs_cleanup_item 두 사건이 application-local.yml을 PostgreSQL 기본으로 바꿨다. (05 §8.5)
  19. 계약 테스트는 SQL을 재타이핑하지 말고 어댑터가 실제로 돌리는 statement를 실행해야 한다. "테스트 작성자와 어댑터 작성자가 쿼리에 대해 합의했다"는 아무도 필요로 하지 않는 속성이다. (05 §11.2)
  20. 버그를 고칠 때 왜 그 버그가 가능했는지를 코드 옆에 남긴다. 이 저장소 javadoc의 상당량이 사후 기록이고, 다 읽고 나면 같은 실수가 다시 들어오는 걸 막는 유일하게 작동하는 장치로 보인다. (05 §16)

10. Explicit project decisions

문서·javadoc에 근거가 명시된 설계 결정 중, 읽고 나서 성립을 확인한 것들이다. "왜 이렇게 했나"의 답이 코드 옆에 있는 것만 골랐다.

10.1 계약과 경계

결정 근거 문장 출처
계약 leaf는 framework-free grpc-core-api의 빈 dependencies {} — 클래스패스로 강제 20 §2.1
상태 코드는 미러링하고 번역은 한 곳이 소유 "a failure context that names io.grpc.Status would put the transport inside the contract that exists to describe what the transport did" 20 §2.1
도메인은 리포지토리를 소유하고 플랫폼은 base repository를 만들지 않는다 "모든 애그리거트가 강제로 통과해야 하는 generic API, 그리고 한 애그리거트의 요구가 전부의 동작을 조용히 바꾸는 단일 지점" 05 §1·§5.1
어댑터가 @Transactional 경계를 소유하지 않는다 유스케이스가 TransactionPort로 소유 03 §4
DB 드라이버는 벤더 패키지 밖에 나올 수 없다 ArchUnit PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL 05 §1
중립 port가 adapter 타입 의존을 대체한다 GraphQL persisted-operation registry가 OperationalRecordStorePort에 의존 02

10.2 실패와 불확실성

결정 근거 문장 출처
completion-unknown은 절대 retryable이 아니다 생성자가 그 조합을 거부. 이중 방어로 forceCompletionUnknown 05 §2.2
증거를 먼저 기록하고 결론을 나중에 고른다 "lets an operator answer 'could the broker be holding this message?' from a stored result" 19 §3.2
커밋 모호성 규칙을 넓히지 않는다 "운영자는 그 큐를 읽지 않고 비우는 습관을 배운다 — 그러면 중요했던 한 건이 나머지와 같이 지워진다" 05 §3.5
PropagationMode는 셋뿐 NESTED/SUPPORTS/NOT_SUPPORTED/NEVER는 "호출자의 작업이 트랜잭션 안에 있는지 자체를 조용히 바꾸기 때문에" 없다 05 §2.3
READ_UNCOMMITTED가 없다 PostgreSQL이 READ COMMITTED로 취급하니 "프로파일이 DB가 제공하지 않는 격리를 주장하게 된다" 05 §2.3
미인식 SQLSTATE는 추측하지 않는다 "미지의 상태를 직렬화 실패로 분류하면 재시도 코디네이터가 이미 성공한 쓰기를 기꺼이 다시 돌린다" 05 §7.1
예외 메시지를 버리고 타입만 남긴다 "드라이버 메시지는 routing key, payload 조각, connection string을 담을 수 있고 FailureDescriptor는 로깅·export되도록 설계됐다" 19 §3.2

10.3 조립과 활성화

결정 근거 문장 출처
마스터 스위치는 루트 하나가 소유하고 자식은 조건을 갖지 않는다 "a bean added to any child next month is gated without anyone remembering to repeat a condition" 19 §6.1 · 05 §14.1
전송 선택은 classpath 사고가 아니라 속성 "Nothing failed; the message simply went somewhere nobody chose." 19 §6.1
"꺼짐"은 구조적이어야 한다 빈 없음, 소켓/풀/스레드 없음, 설정 미바인딩, 스키마 기대치 없음 05 §16 · 19 §6.1
프레임워크 자동설정까지 막는다 프로젝트 조건만으로는 부족 — JPA/Flyway starter가 Boot의 import metadata로 자기 것을 기여한다 05 §14.1
질문은 "JPA가 켜졌나"가 아니라 "관계형 커넥션이 필요한 capability가 있나" 풀은 JPA의 사유물이 아니다 — outbox, JDBC idempotency, multi-instance lock, notification, fileserver가 전부 필요로 한다 05 §14.1
클래스패스에 있는 것은 동의가 아니다 experimental 게이트를 static이 아니라 생성자 파라미터로 받아 "컴파일러가 요구하는 인자를 잊을 수 없게" 05 §13
admin plane의 파괴적 작업은 자동설정하지 않는다 "an operator tool that needs purge or delete registers one itself, with an admin credential this runtime does not hold" 19 §8.1

10.4 데이터와 경계값

결정 근거 문장 출처
인코딩 한도는 보고 기준이 아니라 할당 경계 "a process-wide outage caused by one message" — BoundedByteSink가 한도를 넘기는 write에서 실패 19 §4.1
기본 코덱을 "먼저 등록된 것"으로 고르지 않는다 "a wire-format decision made by accident" 19 §4.2
raw-bytes 코덱은 기본이 될 수 없다 "인코딩을 선언하지 않은 모든 destination이 스키마 검증을 조용히 건너뛴다" 19 §4.2
keyset 페이지네이션에 offset 필드를 두지 않는다 "필드의 부재가 나중에 하나 추가되는 걸 막는다" 05 §2.4
keyset에 total count도 page number도 없다 같은 predicate에 두 번째 집계 쿼리가 필요하고 "움직이는 데이터셋에서 그 숫자는 클라이언트에 닿기 전에 이미 낡았다" 05 §2.4
커서를 서명하는 이유는 기밀성이 아니라 무결성 "서명 없는 커서는 클라이언트가 제어하는 정렬 상태이고 ... 접근 제어 우회다" 05 §2.4
JSONB 문서에 타입 메타데이터를 넣지 않는다 "문서 안의 타입 메타데이터는 JSONB 컬럼을 역직렬화 가젯으로 만든다" 05 §7.5
범위는 두 컬럼이 아니라 range 타입 "[09:00, 10:00)[10:00, 11:00)이 겹치는지는 값이 아니라 bracket에 달렸다" 05 §7.5
tenant id는 절대 메트릭 태그가 되지 않는다 카디널리티가 정의상 unbounded이고 "텔레메트리 안의 tenant id는 그렇게 취급되지 않는 시스템 안의 고객 데이터" 05 §13.2

10.5 증거와 게이트

결정 근거 문장 출처
등급은 boolean이 아니라 증거에서 파생 "As a field it was a boolean an author set next to the tier" 19 §6.6
in-process 결과로 전송 능력을 주장할 수 없다 GrpcEvidenceGrade.requireCertifiesCONTRACT 등급의 tls 주장은 예외 20 §2.4
성능 레인은 릴리스 게이트에 넣지 않는다 "a measurement in the release gate is a flaky test on a shared CI runner" 20 §2.5 · 05 §15.3
인증 레인만 Docker 가드를 달지 않는다 "a lane that skipped would report success for a broker nobody started" 19 §2.3
문서 부재는 후속 과제가 아니라 릴리스 차단 사유 "the first person to meet it is the one who has to work it out at three in the morning" 20 §3.2
지원 수준은 추론이 아니라 선언 "evidence suite가 돌지 않은 capability는 컴파일이 된다는 이유로 STABLE이 되지 않는다" 05 §2.5
Repair는 모드가 아니다 "Repair는 그 질문을 물을 수 없게 만들어서 답한다" — schema history를 다시 써서 증거를 지운다 05 §8.1
예약 헤더 위조 방어 "A forged msg.id corrupts another message's inbox deduplication" 19 §6.8
actuator 엔드포인트는 읽기 전용이고 재식별 표면을 만들지 않는다 "a diagnostic that is worse than absent, because it looks like an answer" 19 §8.4
설정 오타를 바인딩 섹션 안에서 거부하고 적법 키를 settings record에서 파생 "the one setting the operator came to change is the only one that did not take" 19 §9.1

11. Unresolved questions

각각 왜 답하지 못했는지 적는다. 원본은 99 §10.

1. 컨테이너·브로커·DB가 필요한 레인의 실제 결과. §6.2 목록. 이들이 통과한다는 것은 문서와 커밋된 manifest의 주장이고, 그중 messaging 인증만 CI가 강제한다. 가장 값싼 해소: Docker가 있는 환경에서 ./gradlew jpaPlatformReleaseGate:messaging:messaging-kafka:verifyMessagingCertificationEvidence 두 개를 돌리는 것.

사이클 2에서 절반 해소했다. JPA 태그 레인 다섯은 a24ece9c에서 244 tests · 0 failures로 통과했다(EVD-115, §6.2 정정). 남은 것은 HEAD에서의 재실행, readiness task 2건의 실패 (EVD-117·EVD-118), messaging 인증 레인이다.

2. sample-portfolio의 내부 (279파일 · main 201 · 스테레오타입 50). 사용자 지시로 제외했다. 두 번째 런타임 조합이고 8개 leaf를 갖는다 — adapter-inbound-web, persistence-jpa, objectstorage(이 조합에만 출하), identifier, domain-core, application-core, shared-contract. 따라서 14·05·09의 판정 일부는 이 조합에서 다르게 나올 수 있다.

3. 런타임 관측. 이 분석은 전부 정적이다. 부팅하지 않았고, 로그·액추에이터 응답·메트릭 시리즈를 보지 않았다. evidence/browser·evidence/terminal·evidence/svg가 비어 있는 이유다.

가장 값싼 해소가 여기 있다: 19 §5.1(관측 경로 no-op)은 부팅 후 /actuator/metricsmessaging.* 시리즈가 없다는 것으로 1분 만에 확증된다. 같은 부팅에서 ConditionEvaluationReport를 켜면 19 §7.1(outbox 조건 사슬)도 함께 확정된다.

4. @ConditionalOnBean 사슬의 실제 평가 순서. Spring의 조건 평가는 등록 순서에 민감하고, 정적 읽기로는 "이 조건이 만족될 수 있는가"까지만 판정했다. 05 §14.4가 기록한 사고 — @ConditionalOnBean(DataSource.class)클래스 파싱 시점에 평가되어 모든 실제 배포에서 false였고 여덟 빈이 조용히 사라진 것 — 가 이 질문이 사소하지 않다는 증거다.

5. 성능·용량 주장. 어떤 모듈에서도 측정하지 않았다. 20 §2.5가 확인한 것은 성능 레인이 존재하고 기본 test에서 제외됐다는 사실까지다. 05 §15.3의 jpaPlatformPoolContractTest도 지금은 행동 계약만 검증하고 threshold를 갖지 않는다.

6. gRPC 도메인 로직 정확성. 20 §7의 읽기는 초점이 동시성과 경계였다. grpc-proto-contract의 스키마 규칙 판정(3 main / 605 LOC), grpc-codegen의 매니페스트 해시 규약, grpc-advanced-resilience의 hedging 적격성·xDS 실패 정책, grpc-advanced-compat의 Servlet/gRPC-Web 프로파일 판정은 구조와 도달성만 확인했다.


12. Evidence index

evidence/raw 297 파일 (번호 최대 268). 각 파일 헤더에 리비전·cwd·명령·원본 출력
evidence/meta 6 파일
evidence/browser · terminal · svg 비어 있음 — 정적 분석만 수행 (§11.3)
source-index.md SRC 173행 · EVD 81행. 각 행이 sub-scope ↔ 증거 파일 ↔ 판정을 연결
모듈 문서 §A0120, 각 문서 말미에 커버리지 원장과 §검증
교차 종합 §A99
상태 정본 state.json — leaf 62개의 status · analysisFile · sourceRevision · coverage · notes 5건

증거 파일은 원본 출력만 담는다. 가공한 표는 전부 모듈 문서에 있고, 그 표의 각 숫자가 어느 증거 파일의 어느 줄에서 왔는지는 source-index.md의 해당 SRC 행이 기술한다.


13. Limits of this analysis

1. 정적 분석이다. §11.3.

2. 컨테이너 의존 레인 — 사이클 2에서 일부 해소. 사이클 1 시점의 제약이었다. 사이클 2는 이 컨테이너에 Docker가 있음을 확인하고(client 29.1.3 / server 29.6.1) Testcontainers 기반 lane을 실제로 돌렸다 — persistence-jpa·mongo·cache-redis 포함. 여전히 기동하지 않은 것은 compose 스택 자체다(§14, EVD-334).

3. sample-portfolio 제외. 사용자 지시(2026-08-30). §11.2.

4. 리비전 이동을 겪었다. 분석 도중 코드베이스가 a24ece9c21234e38으로 이동했다.

git diff --stat a24ece9c..HEAD → 400 files changed, 40217 insertions(+), 4 deletions(-)
변경 경로: src/grpc* · modules.json(18항목 추가) · src/build.gradle · docs 15개

차이가 gRPC 가족 추가에 국한됨을 diff로 확인한 뒤 모듈 20으로 닫았다. 모듈 01~19가 다룬 경로는 변경되지 않았다. (20 §0, 99 §8)

5. 정정 다섯 건이 있었다. 이미 닫은 모듈로 되돌아가 판정을 바꾼 것 넷 + 분모 정정 하나.

# 대상 언제 발견 정정
1 14 §8.1 15 grpc 분석 중 "한 API가 두 에러 와이어 형식을 낸다" → "RFC 9457 23파일이 출하 애플리케이션에 등록되지 않는다"
2 17 §4.1 18 app-bootstrap 분석 중 P1 → P2. runtime_memberships=[]가 기계 강제되는 build-only 등급임을 확인
3 18 §4.1 자기 초안 검토 중 "출하 스위치가 다섯보다 많다" → 결함 아님 + 더 좁은 §4.1b/§4.1c
4 14 §8.1 교차 분석 중 "넘겨받을 자동설정이 작성되지 않았다" → 자동설정 둘은 존재하고 import되며, 협력자만 소유하고 여섯 컴포넌트를 소유하지 않는다
5 분모 교차 분석 중 state.json 44 스코프 vs 레지스트리 62 — 리비전 이동이 원인, 모듈 20으로 해소

전부 원인이 같다 — 조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다.

네 번째 정정이 특히 중요하다. 결함을 없애지 않고 더 좁고 정확하게 만들었고, 그 결과 권고가 "새 자동설정을 만든다"에서 "기존 자동설정에 @Bean 여섯 개를 추가한다"로 작아졌다.

6. 모듈별 읽기 깊이가 균일하지 않다. 커버리지 원장의 FULL_READ/STRUCTURAL_ONLY 비율이 모듈마다 크게 다르다. 20(gRPC)은 2026-08-31 보강으로 383 파일 중 전문 판독 45개가 됐고 (20 §7, 동시성·경계 초점) 그 결과 P2가 4건에서 10건이 됐다. 남은 미독은 도메인 로직 정확성이다(§11.6). 각 모듈의 실제 비율은 그 문서의 커버리지 원장이 정본이다.

7. P1/P2/P3 총계 — 사이클 2에서 제시한다. 사이클 1은 표기 형식이 갈려 기계 집계가 중복된다는 이유로 총계를 내지 않았다. 사이클 2는 두 형식(모듈 findings 표 · §17 ### P<n> 헤딩)을 모두 파싱하고 문서 내 중복을 제거해 422건(P1 29 · P2 135 · P3 258)을 얻었다. 다만 이 총계는 정본 목록만 센 것이고, 본문 산문 안의 언급은 포함하지 않는다. 모듈별 확정 수치는 여전히 각 문서의 §발견 종합이 정본이다.

그 뒤 23개 리프를 다시 읽으면서 그 몫이 60→100 으로 늘었고, 총계를 462건(P1 30 · P2 147 · P3 285)으로 조정했다. 조정은 델타이지 재측정이 아니다 — 23개 리프의 100건은 직접 셌고, 나머지 38개 문서는 이번 재작업의 대상이 아니어서 두 형식 파서를 다시 돌리지 않았다. 462는 422가 정확했다는 가정 위에 있다.


14. 사이클 2 — 18개 리프 재검증과 23개 리프 전수 통독

14.1 18개 리프 재검증

사이클 1이 남긴 18개 리프 문서(0118)를 HEAD에서 다시 검증했다. 전량은 §A99에 있고, 여기서는 이 정본 문서의 판정에 영향을 준 것만 옮긴다.

소스는 움직이지 않았다. 18개 문서가 기준으로 삼은 a24ece9c와 HEAD 21234e38 사이는 커밋 하나이며, 그 커밋은 grpc 블록과 공통 파일 둘만 건드렸다. 18개 리프 경로의 변경 파일 수는 전부 0이고, 공통 파일 둘도 이 18개에 영향이 없다 (EVD-333). 따라서 §1~§13의 서술은 HEAD에서도 유효하다.

lane을 다시 돌렸다. 실패 5건 — httpclient 3 · fileserver 1 · app-bootstrap 1. 셋 다 실행 환경 결손이며 프로덕션 결함이 아니다 (EVD-332, EVD-334).

리프 사이클 1 판정 사이클 2 재측정
08 fileserver 환경(로케일) 확인 — LANG=C.utf8에서 통과
18 app-bootstrap 환경(jq 부재) + 가드 비대칭 P3 확인 + 공백 보강 — 15개 레인 계약을 독립 경로로 검증
11 httpclient P1 제품 결함 철회 — 픽스처의 듀얼스택 호스트명이 원인

철회된 P1. 사이클 1은 ApacheFailureClassifier의 분기 순서를 읽고 "Apache가 TLS 실패를 HttpHostConnectException으로 감싸므로 CONNECT 분기가 TLS 분기를 가린다"고 결론했다. 예외 사슬을 실제로 출력하면 그 사슬에 SSLHandshakeException이 없다. 원인은 MockHttpServer.uri()가 호스트명 localhost를 돌려주는데 이 컨테이너의 localhost127.0.0.1::1 양쪽으로 풀리고 MockWebServer는 IPv4에만 바인딩한다는 것이었다. Apache의 다중 주소 루프가 첫 주소의 진짜 TLS 실패를 삼키고 마지막 주소의 연결 거부만 승격시킨다. 접속 호스트를 127.0.0.1로 바꾸면 세 건 모두 TLS_PERMANENT가 된다 (EVD-332).

남는 것은 두 가지다. 픽스처가 호스트명을 쓰는 것(P3), 그리고 다중 주소 호스트에서 패밀리별 실패 양상이 다르면 영구 TLS 실패가 재시도 가능한 CONNECT로 강등될 수 있다는 성질(P2/기록, 이 모듈에서 고칠 수 없다).

17/18은 확인, 1/18은 번복. 이 비율이 사이클 1 문서에 대한 이 사이클의 측정치다.

finding 총계를 처음으로 제시한다. 61개 COMPLETE 문서의 정본 목록만 파싱해 422건 (P1 29 · P2 135 · P3 258)을 얻었다. 두 가지 표기 형식 — 모듈 findings 표, 그리고 §17 「손볼 것」의 ### P<n> 헤딩 — 을 모두 읽고 문서 내 중복을 제거한 수치다.

14.2 23개 리프 전수 통독

재검증과 별개로, 사이클 2 는 FULL_READ_REQUIRED 로 열려 있던 23개 리프(messaging 5 · grpc 18)를 닫았다. 그 리프들의 사이클 1 문서는 production 구현을 STRUCTURAL_ONLY 로 판정하고 파일 이름·LOC·build.gradle 주석으로 서술한 상태였다.

이 통독은 두 번 했고, 첫 번째는 통독이 아니었다. 첫 판에서 23개 리프를 FULL_READ_DONE 으로 표시하고 "395파일 전수 통독" 이라고 적었으나, 리프마다 실제로 읽은 것은 일부였다. Coverage ledger 가 사실이 아닌 상태였으므로 23개 리프를 파일 단위로 다시 세고 처음부터 다시 읽었다. 정직한 분모는 이렇다.

대상 리프 main 파일 main 줄 test 파일 test 줄
grpc 계열 18 260 18,726 73 10,719
messaging 계열 5 97 10,816 48 9,037
합계 23 357 29,542 121 19,756

통독 후 STRUCTURAL_ONLY 잔여는 0 이고, 23개 SSOT 의 Coverage ledger 를 이 숫자로 다시 썼다.

23개 문서의 finding 은 100건(P1 2 · P2 31 · P3 67)이고, 그중 8건은 가족 문서(19·20)가 이미 기록한 것을 canonical 리프로 옮긴 것이다. 통독에서 처음 나온 것은 92건 — P1 2 · P2 25 · P3 65.

앞선 판이 "P1 0" 이라고 적은 것은 부분 통독의 결과였다. 다시 읽으니 P1 이 둘 나왔고, 둘 다 messaging 계열이며 둘 다 배선되는 경로 위에 있다.

  1. 운영 프로파일에 TLS 와 브로커 인증을 요구해 놓고, 그 둘이 없는 생산자를 만든다. KafkaProfileValidator 는 전송 보안 없는 운영 프로파일의 기동을 거부하고 테스트가 그 거부를 지킨다. 그런데 실제 조립되는 KafkaProducer 설정에는 security.protocol 이 없다 — Kafka 기본값 PLAINTEXT 다. 그 값을 만드는 KafkaSecurityConfigurer 의 production 호출자는 저장소 전역에서 0 이다. (messaging/messaging-spring-boot-starter §17.1)
  2. 지원 문서가 deduplicatedPublish 를 지원으로 적고 코드는 거짓이며, 그 차이가 정확히 코드가 경고한 피해다. (messaging/messaging-kafka §17.1)

그 아래 층위에서는 앞선 판의 성격 서술이 그대로 유효하다 — 새 사고가 아니라 이미 알려진 패턴이 블록 안 어디에서 성립하는지 를 찾은 것이다. 가족 문서는 "블록 전체가 미배선" 층위에서 멈췄고, 통독은 그 블록이 배선되더라도 성립하지 않을 것들을 찾았다.

§1~§13 에 대한 영향. 앞선 판은 "없다" 고 적었다. 정정한다 — 위 두 P1 은 출하 경로 위에 있다. messaging-spring-boot-startermessaging-kafkaapp-bootstrap 소속이므로(§2 의 미소속 27개 목록에 없다) grpc 블록과 달리 "켜면 성립하는" 층위가 아니다. 나머지 90건은 여전히 미배선 블록 안쪽이고, 그 부분에 대해서는 §8.2 와 §5.5 의 반영이 그대로 유효하다.

가장 무거운 셋(미배선 블록 안쪽).

  1. JpaGrpcOperationLedger.claim 의 insert-first 주장이 Spring Data 의 save 계약과 어긋난다. 엔티티 식별자가 배정값이라 savemerge 로 가고, 파생 기본 키(caller|method|keyHash)가 유니크 제약과 같은 행을 가리키므로 두 번째 청구가 위반을 일으키지 않고 커밋된 결과를 덮어쓴다. 테스트 이중의 save 가 INSERT 를 흉내 내 그 차이를 가린다. (grpc/grpc-operation-ledger-jpa §17.1)
  2. GrpcCredentialRotationManager.completeDrain() 이 진행 중인 회전을 되돌린다. 읽기와 쓰기 사이에 회전이 일어나면 방금 교체된 자격증명이 다시 현재가 된다. (grpc/grpc-policy §17.2)
  3. GrpcPlatformStartupValidator 가 시작 시 실행되지 않는다. 그 검증기가 유일한 소비자인 설정 키 넷이 아무것도 게이트하지 않는다. (grpc/grpc-spring-boot-starter §17.1)

통독이 만든 새 형태 셋 — 상세는 §A99 §3.5 · §3.2 · §3.7.

형태 요지
원자 타입을 쓰면서 비교 후 교체를 하지 않음 5 정본이 같은 저장소에 둘 있다(GrpcRetryBudget·GrpcHedgingBudget)
선언되고 주입되지 않는 검증기 9 재통독이 다섯을 더 찾았다 — 아래
능력 선언이 프로파일에서 파생되지 않음 3 NATS 의 deduplicatedPublish 가 가장 무겁다 — 부재가 예외를 만드는 유일한 플래그다
게이트라고 적힌 채 조립되지 않음 5 검증기 9 중 다섯. 문서가 "이것이 빌드를 실패시킨다" 고 단언한다
선언만 있고 코드가 닿지 않는 project 의존 6 의존 그래프가 코드보다 넓다
테스트 이름이 검사하지 않는 것을 검사한다고 말함 5 커버리지 지도가 틀린다

넷째 형태가 이번 재통독의 가장 큰 수확이다. 앞의 "선언되고 주입되지 않는 검증기" 는 공백이지만, 이 다섯은 오해다 — 문서가 그 게이트가 돈다고 단언하기 때문이다.

게이트 상태 그렇게 적은 곳
GrpcProtoContractValidator 코드 호출자 0 · 빌드 파일 어디에도 없음 buf.yamlGrpcBufPolicy 가 각각 "이것이 이 저장소의 빌드를 실패시킨다" 고 적는다
GrpcBufPolicy 의 네 Buf 태스크 어떤 빌드 파일에도 없음 javadoc 이 "a missing stage is a test failure rather than a stage nobody noticed was gone" 라고 적는다
GrpcAdvancedModuleGuard.requireStableStarterIsClean 호출자 0 javadoc 이 "a fat jar, a shaded artifact, a test harness — is checked too" 라고 적는다
NatsJetStreamProfileValidator 코드 0 · 테스트 0 · 흔적은 javadoc {@link} 한 줄 전송 javadoc 이 "refuses the combination at startup" 이라고 적는다
PulsarProfileValidator 자기 선언 한 줄 말고 저장소 전체에 없음 그 리프 SSOT 가 검증을 서술했다(이번에 정정)

앞의 둘이 서로를 가리킨다 — buf.yaml 은 CLI 가 없으니 자바 검증기가 게이트라고 하고, GrpcBufPolicy 는 자바 검증기가 실제 게이트라고 한다. 두 쪽 다 상대가 게이트라고 말하고 어느 쪽도 실행되지 않는다.


부록 A. 모듈 문서 지도

문서 대상 leaf 이 문서에서
§A00 141 초기 sizing 스냅샷 §1
§A01 215 domain-core 1 §2.3
§A02 161 shared-contract 1 §2.3 · §5.1
§A03 379 application-core 1 §3.2 · §3.5
§A04 645 support 1 §8.1 #12·#13
§A05 4,674 persistence-jpa 1 §4.1 · §5.15.3 · §6.3·6.5 · §10
§A06 1,496 persistence-mongo 1 §4.2
§A07 188 identifier 1 §7.1 · §7.4
§A08 656 fileserver 1 §4.4
§A09 793 objectstorage 1 §4.4
§A10 933 cache-redis 1 §4.4 · §7.3
§A11 716 httpclient 1 §8.1 #11
§A12 379 adapter-outbound-messaging 1 §2.4
§A13 1,062 notification 1 §3.5
§A14 1,702 web 1 §3.1 · §7.1 · §8.1
§A15 244 adapter-inbound-grpc 1 §2.3
§A16 1,122 graphql 1 §7.1 · §7.5
§A17 546 websocket 1 §8.3
§A18 571 app-bootstrap 1 §5.3 · §7.2
§A19 1,284 messaging 가족 25 §3.3 · §4.3 · §5.2·5.4 · §6.4
§A20 716 gRPC 가족 18 §3.4 · §5.5 · §7.6
§A99 364 교차 §7 · §9

분석 단위와 문서 단위가 1:1이 아니다. messaging 25 leaf와 gRPC 18 leaf는 leaf 경계를 넘는 계약(capability 선언 → validator → 인증 증거 → 지원 문서)이 실제 설계 단위라 각각 한 문서로 통합했다. state.jsonscopes가 leaf 단위 정본이고, 각 항목의 analysisFile이 담당 문서를 가리킨다.

부록 B. 자주 쓸 명령

cd src

# 아키텍처 경계
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyRuntimeModuleMembership verifyDocumentedLeafCount --console=plain

# 플랫폼 가족 전량
./gradlew $(python3 - <<'PY'
import json,io
d=json.load(io.open("config/architecture/modules.json"))
print(" ".join(m["gradle_path"]+":test" for m in d["modules"]
               if m["id"].startswith("messaging-")))
PY
) --console=plain
./gradlew $(python3 - <<'PY'
import json,io
d=json.load(io.open("config/architecture/modules.json"))
print(" ".join(m["gradle_path"]+":test" for m in d["modules"]
               if m["id"].startswith("grpc-")))
PY
) --console=plain

# gRPC 증거 레인 (Docker 불필요, 실제 소켓)
./gradlew :grpc:grpc-testkit:grpcInProcessContractTest \
          :grpc:grpc-testkit:grpcNettyContractTest \
          :grpc:grpc-testkit:grpcFaultTest --console=plain

# 컨테이너 필요 — 이 분석에서 실행하지 않은 것들
./gradlew jpaPlatformReleaseGate                                            # 실 PostgreSQL 6레인
./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence   # 실 Kafka + 게이트
./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence
./gradlew :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence -PjpaEvidenceProfile=r2

# SQL 안전 게이트
./gradlew :adapter:outbound:persistence-jpa:verifyJpaSqlConstructionSafety
./gradlew :adapter:outbound:persistence-jpa:verifyJpaSecurityFixtures

# §11.3의 값싼 확증 (부팅 1회로 P2 두 건 확정)
#   1) app-bootstrap 부팅 후 /actuator/metrics 에 messaging.* 시리즈 부재 확인  → 19 §5.1
#   2) 같은 부팅에 debug=true 로 ConditionEvaluationReport 확인               → 19 §7.1

부록 C. 다시 읽는다면 이 순서

  1. src/config/architecture/modules.json — leaf 정체와 runtime membership
  2. 각 가족의 CLAUDE.md — 규칙과 계약 표 (src/messaging/, src/grpc/, src/grpc-advanced/)
  3. src/app-bootstrap/.../CaSkeletonApplication.java — 스캔 경계와 AUTO_CONFIGURED_PACKAGES
  4. 8개 AutoConfiguration.imports — 조립될 수 있는 것의 전체 집합
  5. shared-contract/apidomain-coreapplication-core port — 계약층
  6. persistence-jpa/api + transaction — 가장 밀도 높음
  7. messaging-core-api + messaging-runtime-core/DefaultMessagePublisher — 단일 publish 경로
  8. 각 leaf의 build.gradle + config/** + gradle/*-evidence.gradle — 검증 체계
  9. docs/**/support-matrix.md — 무엇을 약속했는가 (그리고 §7.4로 대조)

제2부 — 모듈 분석 전문

제1부는 이 부의 종합이다. 여기 실린 것이 근거이고, 분석하는 동안에는 analysis/ 아래에 파일로 나뉘어 있었다. 파일이 아니라 이 문서가 정본이므로 그대로 옮겨 왔다 — 제목 수준만 내렸고 본문은 손대지 않았다.


A00. project-overview

분석 중에는 00-project-overview.md 파일이었다. 141줄.

Project Overview

이 문서는 분석 시작 시점의 sizing 스냅샷이다. 최종 결과는 final/document.md, 교차 스코프 종합은 §A99, 모듈별 확정 수치는 제2부 각 절의 커버리지 원장이 정본이다. 아래 파일/LOC 표와 bounded scope 목록은 갱신하지 않는다 — 스냅샷으로서의 값이 그 정확성이기 때문이다.

분석 기준 revision

  • repository: /shared/codebase/clean-architecture-backend-template
  • 최초 기준선: a24ece9cf797f7ea647e33bf846b115208ed1ba5 (모듈 01~19)
  • 최종 기준: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 — 분석 도중 커밋 21234e38("feat: grpc 기능 deep 구현", 2026-08-31)이 gRPC 가족 18 leaf를 추가했다. git diff a24ece9c..HEAD = 400 files / +40,217 / 4이고 변경 경로가 src/grpc* · modules.json · src/build.gradle · docs 15개뿐이어서 모듈 01~19는 영향받지 않는다 (§A20 §0, §A99 §8).
  • working tree: clean

최종 커버리지

등록 leaf (최종) 62 (최초 스냅샷 시점 44)
COMPLETE 61
EXCLUDED 1 — sample-portfolio (사용자 지시 2026-08-30)
PENDING 0
모듈 문서 §A0120 (20개). messaging 25 leaf는 19, gRPC 18 leaf는 20으로 통합
교차 스코프 §A99
최종 문서 final/document.md

분석 단위와 문서 단위가 1:1이 아니다. 아래 "분석할 bounded scopes" 표는 leaf마다 문서 하나를 계획했으나, messaging:* 25개와 grpc:*·grpc-advanced:* 18개는 leaf 경계를 넘는 계약 (capability 선언 → profile validator → 인증 증거 → 지원 문서)이 실제 설계 단위여서 각각 한 문서로 통합했다. state.jsonscopes가 leaf 단위 정본이고, 각 항목의 analysisFile이 담당 문서를 가리킨다.

Build and module map

  • Gradle 9.0.0 wrapper 기반 멀티모듈 build이며 src/settings.gradle이 included build build-logicca.architecture-registry settings plugin을 적용한다.
  • src/config/architecture/modules.json이 leaf 존재/경로/허용 project dependency/runtime membership의 SSOT다.
  • registry leaf 수: 이 스냅샷 시점 44, 최종 62. 숫자는 측정치이며 문서 규칙의 별도 SSOT로 취급하지 않는다 — modules.json이 SSOT다.
module Gradle path production files/LOC* test files/LOC* runtime membership status
domain-core :domain-core 8 / 107 0 / 0 app-bootstrap, sample-portfolio COMPLETE
shared-contract :shared-contract 56 / 2839 23 / 2706 app-bootstrap, sample-portfolio PENDING
application-core :application-core 886 / 35798 136 / 19751 app-bootstrap, sample-portfolio PENDING
adapter-outbound-support :adapter:outbound:support 5 / 107 1 / 89 app-bootstrap PENDING
adapter-outbound-persistence-jpa :adapter:outbound:persistence-jpa 460 / 44594 141 / 12581 app-bootstrap, sample-portfolio PENDING
adapter-outbound-persistence-mongo :adapter:outbound:persistence-mongo 353 / 23401 139 / 15416 app-bootstrap PENDING
adapter-outbound-identifier :adapter:outbound:identifier 5 / 134 2 / 151 app-bootstrap, sample-portfolio PENDING
adapter-outbound-fileserver :adapter:outbound:fileserver 79 / 12723 37 / 12043 app-bootstrap PENDING
adapter-outbound-objectstorage :adapter:outbound:objectstorage 154 / 14966 49 / 6778 sample-portfolio PENDING
adapter-outbound-cache-redis :adapter:outbound:cache-redis 316 / 33690 70 / 15245 app-bootstrap PENDING
adapter-outbound-httpclient :adapter:outbound:httpclient 270 / 15887 97 / 8803 app-bootstrap PENDING
adapter-outbound-messaging :adapter:outbound:messaging 35 / 4350 19 / 3670 app-bootstrap PENDING
adapter-outbound-notification :adapter:outbound:notification 172 / 14749 61 / 10037 app-bootstrap PENDING
adapter-inbound-web :adapter:inbound:web 428 / 29488 202 / 23500 app-bootstrap, sample-portfolio PENDING
adapter-inbound-grpc :adapter:inbound:grpc 9 / 650 6 / 782 none PENDING
adapter-inbound-graphql :adapter:inbound:graphql 409 / 26477 117 / 14745 app-bootstrap PENDING
adapter-inbound-websocket :adapter:inbound:websocket 174 / 13426 74 / 10336 none PENDING
app-bootstrap :app-bootstrap 160 / 15096 282 / 32724 app-bootstrap PENDING
sample-portfolio :sample-portfolio 210 / 10429 63 / 7020 sample-portfolio PENDING
messaging-core-api :messaging:messaging-core-api 86 / 3952 8 / 934 app-bootstrap PENDING
messaging-schema-api :messaging:messaging-schema-api 11 / 635 3 / 263 app-bootstrap PENDING
messaging-schema-json :messaging:messaging-schema-json 2 / 234 3 / 373 app-bootstrap PENDING
messaging-schema-avro :messaging:messaging-schema-avro 3 / 356 4 / 463 none PENDING
messaging-schema-protobuf :messaging:messaging-schema-protobuf 3 / 209 2 / 278 none PENDING
messaging-cloudevents :messaging:messaging-cloudevents 4 / 243 1 / 162 app-bootstrap PENDING
messaging-policy :messaging:messaging-policy 27 / 1744 4 / 1236 app-bootstrap PENDING
messaging-transport-spi :messaging:messaging-transport-spi 14 / 783 4 / 586 app-bootstrap PENDING
messaging-runtime-core :messaging:messaging-runtime-core 7 / 804 4 / 866 app-bootstrap PENDING
messaging-observability :messaging:messaging-observability 10 / 847 6 / 827 app-bootstrap PENDING
messaging-security :messaging:messaging-security 13 / 959 3 / 475 app-bootstrap PENDING
messaging-kafka :messaging:messaging-kafka 36 / 3711 24 / 4087 app-bootstrap PENDING
messaging-kafka-share-experimental :messaging:messaging-kafka-share-experimental 5 / 200 1 / 112 none PENDING
messaging-rabbit :messaging:messaging-rabbit 22 / 2624 10 / 1727 app-bootstrap PENDING
messaging-reliability-api :messaging:messaging-reliability-api 14 / 822 0 / 0 app-bootstrap PENDING
messaging-outbox-jdbc-postgresql :messaging:messaging-outbox-jdbc-postgresql 19 / 2525 8 / 2140 app-bootstrap PENDING
messaging-inbox-jdbc-postgresql :messaging:messaging-inbox-jdbc-postgresql 8 / 576 4 / 607 app-bootstrap PENDING
messaging-claim-check :messaging:messaging-claim-check 7 / 424 3 / 333 app-bootstrap PENDING
messaging-admin-api :messaging:messaging-admin-api 26 / 1619 1 / 147 app-bootstrap PENDING
messaging-admin-runtime :messaging:messaging-admin-runtime 13 / 1263 6 / 1051 app-bootstrap PENDING
messaging-pulsar-experimental :messaging:messaging-pulsar-experimental 9 / 676 2 / 414 none PENDING
messaging-nats-experimental :messaging:messaging-nats-experimental 8 / 768 2 / 460 none PENDING
messaging-spring-cloud-stream-bridge :messaging:messaging-spring-cloud-stream-bridge 7 / 516 2 / 295 none PENDING
messaging-spring-boot-starter :messaging:messaging-spring-boot-starter 29 / 3590 12 / 2384 app-bootstrap PENDING
messaging-testkit :messaging:messaging-testkit 15 / 1246 6 / 828 none PENDING

*파일/LOC 측정은 build output을 제외하고 Java/Kotlin/Groovy/proto/avsc/sql/yaml/properties/json 및 module build.gradle 계열을 대상으로 한 초기 scope sizing 값이다. 이후 각 bounded scope의 coverage denominator는 해당 분석에서 다시 확정한다.

Dependency direction

  • registry상 domain-core의 허용 project dependency는 0개다.
  • application-coredomain-core, shared-contract를 허용하며, adapter/runtime leaf들은 registry에 명시된 방향으로만 project edge를 가질 수 있다.
  • root verifyCleanArchitectureDependencies는 실제 api/implementation/compileOnly/runtimeOnly ProjectDependency 집합과 registry allowlist를 비교한다.

Runtime entry points

  • registry가 인식하는 runtime composition은 app-bootstrap, sample-portfolio 두 개다.
  • domain-core는 두 composition 모두의 membership에 포함된다. 이는 런타임 closure 포함 계약이며, domain-core 자체가 framework entry point나 Spring bean을 가진다는 뜻은 아니다.
  • 각 composition의 실제 wiring/conditional activation은 해당 bounded scope에서 추가 추적한다.

Persistence / messaging / external systems

  • 이 overview에서는 registry와 top-level structure만 확정했다. persistence, messaging, cache, object storage, HTTP client 등은 독립 leaf가 존재하며 세부 동작은 아직 분석하지 않았다.

Test topology

  • 각 leaf의 테스트 소스 수를 초기 계수했다. domain-core 자체에는 Java test가 없고 .gitkeep만 존재한다.
  • 도메인 모델링 규칙은 app-bootstrapCleanArchitectureTest가 production classes 전체를 대상으로 cross-module ArchUnit 검증한다.

Configuration and operational surfaces

  • top-level Docker Compose variants, infra/, runtime configuration이 존재하지만 아직 bounded analysis 전이다.

분석할 bounded scopes (계획 — 실제 문서 배치는 위 "최종 커버리지" 참조)

아래는 분석 시작 시점의 계획이며, 실제 산출물은 다음과 같이 통합됐다:

계획 실제
leaf 1개 = 문서 1개 (44개 문서) 20개 문서
analysis/19-sample-portfolio.md EXCLUDED (사용자 지시)
§A2044: messaging leaf 25개 각각 §A19 하나로 통합
(계획에 없음 — 분석 도중 추가된 가족) §A20 — grpc 18 leaf

계획 표 원본은 git 이력에 남아 있다.

아직 단정하지 않는 것 (분석 시작 시점의 목록)

아래는 이 개요를 쓴 시점의 미결 목록이다. 각 항목의 현재 상태를 병기한다.

  • 각 adapter의 실제 runtime activation 조건과 external system behavior → 해소. 각 모듈 문서 §조립/활성화 절 및 99-cross-scope.md §2.
  • 각 messaging leaf의 production reachability와 experimental/stable 경계 → 해소. 19-messaging-platform.md §1.1(출하 18 / build-only 7)·§10.1.
  • persistence migration ownership과 provider-specific guarantees → 부분 해소. 05·06·19 §7.2가 소유권과 미적용 스트림을 확정했으나, 컨테이너가 필요한 마이그레이션 IT는 실행하지 않았다(99-cross-scope.md §10.1).
  • project 전체가 모든 문서상의 architectural claim을 만족한다는 결론 → 부정. 만족하지 않는 지점이 99-cross-scope.md §2~§5에 형태별로 정리돼 있다.

A01. domain-core

분석 중에는 01-domain-core.md 파일이었다. 260줄.

domain-core 상세 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: domain-core
  • canonical state analysisFile: §A01 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/domain-core · Gradle :domain-core
  • registry allowed_dependencies: []
  • registry runtime_memberships: ["app-bootstrap", "sample-portfolio"]
  • coverage ledger: FULL_READ 10 / STRUCTURAL_ONLY 3 / EXCLUDED 1 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


분석 범위와 결론 상태

  • revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
  • Gradle path: :domain-core
  • registry source path: src/domain-core
  • allowed project dependencies: 없음
  • runtime memberships: app-bootstrap, sample-portfolio
  • scope status: COMPLETE

이 문서는 domain-core 자체의 모든 production Java와 module-local build/docs를 읽고, 이 모듈이 제공하는 식별자 계약과 도메인 stereotype marker가 실제로 어디서 소비되고 어떤 build-time rule로 강제되는지까지 추적한 bounded analysis다. sample-portfolio의 구체 도메인 모델 전체나 app-bootstrap 전체는 이 scope의 소유 대상이 아니며, reachability/enforcement를 증명하는 관련 부분만 읽었다.

1. Quantified scope map

Owned source
  • production Java: 7 files / 107 LOC (package-info.java 2개 포함)
  • module build file: 1 (build.gradle)
  • module docs/instructions: 2 (CLAUDE.md, README.md)
  • dependency lock: 1 (gradle.lockfile)
  • Java tests: 0
  • test placeholder: .gitkeep 2개
  • production packages: dev.caskeleton.domain, .identifier, .stereotype
  • public domain contracts: IdFactory, ResourceId
  • runtime-retained markers: AggregateRoot, DomainEvent, ValueObject

build.gradledependencies {}는 비어 있다. production Java import도 java.lang.annotation.* 외 제3자/framework import가 없다. 따라서 현재 source shape 자체는 module instruction의 “pure domain layer”와 일치한다.

2. Coverage ledger

item/group disposition reason
CLAUDE.md FULL_READ module policy SSOT
README.md FULL_READ explicit design rationale
build.gradle FULL_READ declared dependency surface
gradle.lockfile STRUCTURAL_ONLY inherited build/check/test tooling dependency lock; production dependency edge를 추가하지 않음
identifier/IdFactory.java FULL_READ public identity generation port
identifier/ResourceId.java FULL_READ public resource-id contract
root package-info.java FULL_READ domain package anchor
stereotype/AggregateRoot.java FULL_READ modeling marker
stereotype/DomainEvent.java FULL_READ modeling marker
stereotype/ValueObject.java FULL_READ modeling marker
stereotype package-info.java FULL_READ marker semantics
two test .gitkeep files STRUCTURAL_ONLY no executable test content
generated build/ tree EXCLUDED source/build-output ownership evidence상 generated artifact; current source contract denominator에서 제외

Owned source 기준 unclassified relevant item은 0이다.

3. 이 모듈이 실제로 소유하는 것

관찰: 재사용 가능한 도메인 “내용”보다 도메인 모델링 계약을 소유한다

현재 domain-core에는 WorkLog 같은 실제 aggregate가 없다. 실제 샘플 aggregate/value object/event는 sample-portfolio에 있다. 이 모듈에 남은 production surface는 다음 두 종류다.

  1. 식별자 추상화ResourceId, IdFactory
  2. 모델링 표식@ValueObject, @AggregateRoot, @DomainEvent

따라서 “business concepts, entities, value objects…”를 둘 수 있는 계층이라는 정책과 달리, 현재 snapshot의 실제 contents는 skeleton 전반에서 사용할 domain-layer contract/marker에 가깝다. 이는 현재 source에 대한 관찰이며, 향후 실제 production domain type이 이 module에 추가되지 않는다는 뜻은 아니다.

4. Identifier contract

ResourceId<SELF>

ResourceId는 F-bounded generic marker이며 public surface는 String value() 하나다. README에 기록된 명시적 rationale은 실제 ID 구현이 sample-portfolio에 있으므로 sealed permits로 닫으면 domain-core -> sample-portfolio 역방향 의존이 생긴다는 것이다. 현재 registry에서도 domain-core.allowed_dependencies=[]이고, ModuleRegistry는 production module이 sample-portfolio를 allowlist에 넣는 것 자체를 거부한다. root verifyCleanArchitectureDependencies도 실제 project edge가 registry allowlist를 넘으면 실패한다.

따라서 unsealed 선택의 이유는 문서와 build policy가 서로 일치한다.

다만 ResourceId.value()의 Javadoc/README는 “36-character canonical UUID / RFC 9562 UUIDv7”를 계약처럼 서술하지만 interface 자체는 이를 검증하지 않는다. 실제 샘플 WorkLogId도 현재 regex로 8-4-4-4-12 hex shape만 검사하며 UUID version nibble이 7인지, RFC variant인지 검사하지 않는다. property test acceptsEveryCanonicalUuid는 오히려 임의의 32 hex를 hyphenate한 모든 값을 허용한다고 명시적으로 검증한다.

관찰 결과: “UUID-shaped canonical string”은 현재 검증되지만 “반드시 UUIDv7”이라는 더 강한 서술은 생성 adapter 경로에서는 성립해도 모든 WorkLogId.of(...) 입력 경로의 불변식으로는 강제되지 않는다.

IdFactory<T extends ResourceId<?>>

IdFactorynewId() 하나를 가진다. sample에서는 WorkLogIdFactory extends IdFactory<WorkLogId>로 specialization하고, UuidWorkLogIdFactory가 Spring @Component adapter로 구현한다. adapter는 UuidCreator.getTimeOrderedEpochPlus1()을 호출하고 application use case가 factory를 주입받는 구조가 확인된다.

즉 source dependency는 안쪽의 domain port를 바깥 adapter가 구현하는 방향이며, domain-core는 concrete UUID library/Spring을 모른다.

newId() Javadoc의 “never-before-used”는 타입/저장소 확인으로 강제되는 보장은 아니다. 현재 adapter test는 연속 두 값의 distinctness와 1,000회 monotonic ordering을 확인한다. 이 표현은 생성 전략의 기대 계약이지 전역 uniqueness를 저장소와 대조해 증명하는 메커니즘으로 읽어서는 안 된다.

5. Stereotype markers와 invariants

세 annotation은 모두 @Target(TYPE), @Retention(RUNTIME), @Documented인 framework-neutral marker다.

@ValueObject

marker 자체는 불변성을 구현하지 않는다. CleanArchitectureTest가 annotation 대상 또는 ..domain.vo.. package type에 public no-arg constructor가 없어야 한다고 강제한다. sample의 WorkLogId, Period, WorkLogOwner, PosterId 등이 실제 production consumer다.

따라서 marker의 의미는 **“이 annotation을 붙이면 ArchUnit guardrail의 subject가 된다”**는 build-time qualification이다. 실제 field 불변성/defensive copy 등 모든 value-object 속성을 자동 검증하는 것은 아니다.

@AggregateRoot

sample의 WorkLog, Poster가 실제 production consumer다. CleanArchitectureTestset.* 이름의 method가 public이면 실패시킨다. README와 test description 모두 이 rule이 이름 패턴 밖의 mutator(applyXxx 등)는 포착하지 못한다고 명시한다.

따라서 이 marker는 aggregate consistency를 자동으로 보장하는 annotation이 아니라 특정 위험 surface(public raw setter)를 정적으로 제한하는 qualification marker다.

@DomainEvent

sample의 WorkLogReserved, PosterCreated/Archived/... 등이 사용한다. ArchUnit은 annotation type이 record인지 검사하고 Kafka/Spring HTTP/JAX-RS package dependency를 금지한다. production LiveEventStompBroadcaster는 runtime reflection으로 event class가 @DomainEvent인지 검사하므로 RUNTIME retention은 ArchUnit 외 실제 runtime consumer에도 필요하다.

transport-free rule의 forbidden package list는 exhaustive transport taxonomy가 아니다. test 설명 자체가 “새 broker/transport가 도입되면 list를 확장”해야 하는 구현상 한계를 명시한다.

6. Purity / dependency enforcement

source-level observation

현재 domain-core production code는 Java standard annotation API 외 외부 import가 없다. module dependencies {}도 비어 있다.

project-edge enforcement

src/settings.gradleca.architecture-registry settings plugin을 통해 modules.json을 읽고 각 registered leaf를 include/mapping한다. ModuleRegistry는 다음을 settings time에 fail-closed 검증한다.

  • root/module field set 정확성
  • nonblank id/path
  • duplicate id/Gradle path/canonical source directory
  • source path가 repository root 밖으로 escape하지 않음
  • runtime composition 값
  • self dependency
  • unknown allowed dependency
  • production module의 sample-portfolio 허용 금지

root verifyCleanArchitectureDependencies는 이후 실제 Gradle project dependencies를 registry allowlist와 비교한다. 즉 domain-core에 project dependency가 추가되면 allowed=[]과 불일치해 verification failure가 된다.

class dependency enforcement

CleanArchitectureTest.DOMAIN_IS_PURE..domain.. classes가 Spring/JPA/Hibernate/Lombok/application/adapter/bootstrap/service/infra/presentation/cmd 계열에 의존하지 못하게 한다. 별도 DOMAIN_HAS_NO_LOGGER도 logging framework dependency를 금지한다.

중요한 구분은 이 ArchUnit rule이 domain-core module만이 아니라 package name에 domain이 들어가는 production classes 전체를 subject로 한다는 점이다. 따라서 sample-portfolio domain model도 같은 purity/modeling guardrail의 대상이다.

7. Runtime reachability / wiring

domain-core 자체에는 Spring bean/configuration/entry point가 없다. Registry상 app-bootstrap, sample-portfolio 두 runtime composition에 membership이 있고, concrete consumers가 compile-time type/annotation으로 이 module을 참조한다.

  • ResourceId: application-core messaging contract 및 sample IDs에서 참조
  • IdFactory: sample factory/use-case/identifier adapter에서 참조
  • AggregateRoot: sample aggregate에서 사용
  • DomainEvent: sample events와 websocket broadcaster qualification에서 사용
  • ValueObject: sample IDs/value objects에서 사용

따라서 major public abstraction이 완전히 dead/unwired인 상태는 아니다. 반대로 domain-core가 runtime service를 직접 수행한다는 근거도 없다.

8. Success / failure mechanics

이 module의 runtime executable behavior는 매우 작다. annotation 자체에는 success/failure path가 없고, interface도 implementation을 가지지 않는다. 주요 failure mechanics는 build-time architecture violation이다.

  • forbidden framework/domain dependency → DOMAIN_IS_PURE
  • domain logger dependency → DOMAIN_HAS_NO_LOGGER
  • public no-arg value object → VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR
  • public set* aggregate mutator → AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC
  • non-record domain event → DOMAIN_EVENTS_ARE_RECORDS
  • enumerated transport dependency → DOMAIN_EVENTS_ARE_TRANSPORT_FREE
  • id field raw type not assignable to ResourceIdNO_LONG_ID_PK
  • project dependency not in registry → verifyCleanArchitectureDependencies
  • production -> sample-portfolio edge → settings registry validation and root dependency verification, plus cross-module ArchUnit rule

9. Tests as evidence

:domain-core:test

현재 module에는 executable Java test가 없으므로 이 task의 green result는 domain semantic behavior를 검증한 것이 아니라 module compile/test task가 현재 build에서 정상 구성되고 완료됨을 보여준다. 이번 실행 raw evidence는 evidence/raw/003-domain-core-test.txt에 보존했다.

CleanArchitectureTest

도메인 purity와 marker-specific rules의 실제 enforcement owner다. 별도 app-bootstrap test task로 실행했고 BUILD SUCCESSFUL / exit code 0을 확인했다. raw output은 evidence/raw/004-clean-architecture-test.txt에 저장했다. 이 테스트는 production class import option을 사용해 dev.caskeleton production class graph를 분석한다.

Sample ID tests

WorkLogIdPropertyTest는 UUID shape invariant를 property-based로 검증하지만 UUIDv7 version/variant invariant는 검증하지 않는다. UuidWorkLogIdFactoryTest는 factory output canonical shape, pairwise distinctness, 1,000회 strict lexical monotonicity를 검증한다. 따라서 “factory가 time-ordered UUIDv7 generator를 사용한다”와 “어떤 ResourceId 입력도 v7만 허용한다”는 서로 다른 claim이다.

10. Explicit rationale vs inference

문서로 명시된 rationale
  • ResourceId를 sealed로 만들지 않은 이유: sample module을 production core가 역참조하지 않기 위해서.
  • ID generation 책임(contract)과 concrete generation을 분리한 이유: domain purity 유지.
  • stereotype annotation을 둔 이유: brittle naming convention 대신 explicit declaration을 ArchUnit 기준으로 사용.
  • runtime retention 이유: ArchUnit/reflection reader가 annotation을 볼 수 있게 하기 위해서.
  • aggregate public setter rule 한계는 의도적으로 문서화되어 있음.
분석 inference
  • 현재 domain-core는 구체 business model repository라기보다 skeleton-level domain modeling contract module의 성격이 강하다. 이는 현재 7개 production Java의 실제 내용에서 도출한 해석이다.

11. Improvement backlog

P1 — UUIDv7 계약과 실제 validation의 불일치 확인/정렬

Fact: ResourceId.value()와 README는 RFC 9562 UUIDv7을 서술하지만 WorkLogId regex와 property test는 version/variant를 가리지 않는 모든 canonical UUID-shaped hex 문자열을 허용한다.

Why it matters: 외부/rehydration 경로에서 WorkLogId.of()로 non-v7 UUID가 들어가도 domain invariant가 거부하지 않는다. 생성 adapter가 v7을 만들기 때문에 정상 create path에서 가려질 수 있다.

Verification: WorkLogId.of("00000000-0000-4000-8000-000000000000")가 현재 성공하는지 focused test로 고정하고, 계약 의도가 “shape only”인지 “v7 only”인지 결정한다.

Candidate options:

  1. 계약 문서를 “canonical UUID shape”로 낮춘다.
  2. value object가 UUID version 7 + RFC variant를 실제 검증하고 property test를 수정한다.

Later record candidate: OPEN QUESTION 또는 DECISION. 의도 확인 전 자동 refactor candidate로 단정하지 않는다.

P3 — IdFactory.newId()의 “never-before-used” 문구 정밀화

Fact: interface는 저장소 collision check를 요구하지 않고 sample test도 전역 uniqueness를 증명하지 않는다.

Why it matters: API doc을 강한 guarantee로 읽을 가능성이 있다.

Verification: identifier 설계 문서/역사에서 uniqueness 의미가 probabilistic UUID uniqueness인지 persistence-level uniqueness인지 확인한다.

Later record candidate: REFERENCE 또는 OPEN QUESTION.

12. Limitations / exclusions

  • sample-portfolio business invariants 전체는 이 scope에서 분석하지 않았다. 위 consumer들은 domain-core contract의 reachability/guardrail 의미를 검증하는 데 필요한 부분만 읽었다.
  • CleanArchitectureTest 2,792라인 전체의 다른 architecture rules는 해당 future scope에서 분석한다. 여기서는 domain-core contract와 직접 연관된 rule bodies를 읽었다.
  • runtime composition closure 전체와 conditional startup wiring은 아직 분석하지 않았다.
  • 이 scope의 COMPLETE는 프로젝트 전체 COMPLETE를 의미하지 않는다.

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 9개 (main 5 · test 0 · 기타 4).

src/domain-core/build.gradle
src/config/architecture/modules.json                    (domain-core 항목)

main:
  src/main/java/dev/caskeleton/domain/identifier/IdFactory.java
  src/main/java/dev/caskeleton/domain/identifier/ResourceId.java
  src/main/java/dev/caskeleton/domain/stereotype/AggregateRoot.java
  src/main/java/dev/caskeleton/domain/stereotype/DomainEvent.java
  src/main/java/dev/caskeleton/domain/stereotype/ValueObject.java

기타:
  CLAUDE.md
  README.md
  src/build.gradle
  src/settings.gradle

해석되지 않은 인용 (4종) — 외부 타입·문서상 약칭 등:
  package-info.java
  modules.json
  evidence/raw/003-domain-core-test.txt
  evidence/raw/004-clean-architecture-test.txt


A02. shared-contract

분석 중에는 02-shared-contract.md 파일이었다. 233줄.

shared-contract 상세 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: shared-contract
  • canonical state analysisFile: §A02 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/shared-contract · Gradle :shared-contract
  • registry allowed_dependencies: []
  • registry runtime_memberships: ["app-bootstrap", "sample-portfolio"]
  • coverage ledger: FULL_READ 82 / STRUCTURAL_ONLY 4 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


분석 상태

  • scope: shared-contract
  • source path: src/shared-contract
  • Gradle path: :shared-contract
  • source revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
  • analysis cycle: 1 / normal
  • result: COMPLETE
  • registry dependencies: production project dependency 0
  • runtime memberships: app-bootstrap, sample-portfolio

역할과 경계

shared-contract는 특정 도메인이나 Spring/Jackson/JPA 구현을 소유하지 않고 여러 adapter와 composition root가 공유하는 운영 계약을 보관하는 leaf module이다. build.gradle의 production dependency block은 비어 있으며, CLAUDE.md도 Java standard library only를 명시한다. 실제 production source에서도 Spring/Jackson/JPA type은 관찰되지 않았다.

이 모듈이 제공하는 계약은 단일 관심사라기보다 다음의 skeleton-wide boundary 묶음이다.

  • error taxonomy와 framework-neutral exception carrier
  • API response/bulk/pagination/long-running-operation shape
  • partial-update의 3-state Patch
  • resource:action permission value
  • provider-neutral edge rate-limit contract
  • metric naming/cardinality guardrail
  • traceparent/baggage/span-error seam
  • domain/business context propagation seam
  • compare-and-set operational record storage port
  • adapter master-switch parser
  • Redis semantic health snapshot projection
  • messaging envelope JSON Schema v1와 checked-in SHA-256 digest

따라서 이 module의 핵심 아키텍처적 의미는 "공통 유틸리티"가 아니라, 서로 다른 outer module이 한쪽 adapter의 type에 의존하지 않고 합의할 수 있는 중립 계약 지점이다. OperationalRecordStorePort의 실제 consumer인 GraphQL persisted-operation registry가 inbound adapter 자체의 저장소 interface를 선언하지 않고 이 중립 port에 의존하는 것이 그 방향성을 직접 보여준다.

주요 계약과 불변식

Error contract

ApiErrorCode는 code/category/httpStatus/retryable의 최소 표면을 제공하고 OperationalError가 registry mirror 역할을 한다. Category는 VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL의 10개 값으로 고정되어 있으며 테스트가 정확한 vocabulary를 pin 한다.

OperationalErrorTest는 단순 enum 존재보다 category × retryable 의미를 강하게 검증한다. deterministic VALIDATION/AUTHZ/NOT_FOUND는 retryable=false이고, transient INTERNAL은 기본적으로 retryable=true이되 deploy-time/configuration/terminal 상태인 일부 code는 명시적 예외로 false다. AUTH_KID_UNKNOWN은 key rotation 중 JWKS refresh 가능성을 이유로 AUTH 중 유일한 retryable case로 pin 되어 있다. upstream 4xx 전체를 permanent/non-retryable로 분류하면서 408/429의 의미 차이가 남는다는 점은 source comment와 README가 이미 known edge로 기록한다.

DependencyFailureExceptionPersistenceFailureExceptionApiErrorCarrier를 통해 transport adapter에 stable error code를 전달하면서 raw cause/diagnostic message를 server-side 정보로 남긴다. AdapterDisabledException은 carrier를 구현하지 않고 별도 mapping 대상이다.

Response / operation contract

Envelope, BulkEnvelope, ResponseMeta, PageMeta, Operation은 framework-neutral record/factory로 API shape를 전달한다. 여기서는 중요한 enforcement boundary 차이가 관찰된다.

Envelope.ok/failure, BulkEnvelope.allOk/partial, Operation.pending/succeeded/failed factory는 문서의 정상 shape를 생성하고 테스트도 이 factory path를 검증한다. 그러나 canonical record constructor 자체는 success/data/error의 배타성, operation status와 result/error의 조합, pagination 범위 등을 검증하지 않는다. 따라서 이 규칙은 rate-limit value object처럼 intrinsic constructor invariant가 아니라 factory/adapter usage contract다. 현재 source와 test가 일치하므로 즉시 결함으로 분류하지 않지만, raw constructor가 외부 module에 public인 만큼 invalid shape 생성 가능성은 P1 hardening 후보로 남는다.

Patch<T>는 ABSENT / PRESENT_NULL / PRESENT_VALUE의 3-state를 명확하게 보존하며 absent에서 value()를 호출하면 실패한다. 이는 JSON Merge Patch 계열에서 "필드 미전송"과 "명시적 null"을 구분해야 하는 boundary를 framework type 없이 표현한다.

Permission

Permission은 정확히 한 개의 colon으로 resource:action을 분리하고 trim/lowercase normalization을 수행한다. 테스트는 mixed case, surrounding whitespace, blank component, 0/2+ colon을 검증한다. 다만 source는 component 내부 character set을 제한하지 않는다. 즉 "lowercase colon-delimited"는 normalization 결과이지 [a-z0-9-]+ 같은 strict grammar는 아니다. 현재 test 역시 이를 요구하지 않으므로 observed contract로만 기록한다.

Edge rate-limit contract

이 영역은 shared-contract 안에서도 가장 강하게 self-validating 된다. RateLimitPolicy, RateParameters, RateLimitRequest, RateLimitDecision, RateLimitOutcome, RateLimitEvaluationDedupPolicy가 생성 시점에 bounded representation과 arithmetic safety를 검증한다.

  • Lua exact integer range를 9_007_199_254_740_991로 제한한다.
  • sliding counter/token bucket fixed-point 계산에 scale 1_000_000을 사용하며 중간 합/곱도 exact-range를 넘지 않게 검증한다.
  • window/cleanup/retry duration은 whole milliseconds만 허용하고 상한을 둔다.
  • policy id/revision, subject digest, evaluation id는 bounded regex로 제한한다.
  • raw edge identity는 EdgeRateLimitSubject에서만 잠시 존재하고 provider request에는 pseudonymous digest만 전달하도록 type/regex로 가드한다.
  • v1 failure policy는 FAIL_CLOSED만 허용한다.
  • unavailable / indeterminate / incompatible를 evaluated denial과 분리하여 transport/provider ambiguity를 숨기지 않는다.
  • response-loss replay dedup은 TTL, entry count, logical stored bytes를 동시에 제한한다.

별도 edgeRateLimitContractTest source set이 provider-neutrality와 bounded request semantics를 qualification lane으로 다시 pin 한다.

Metrics and tracing

MetricNaming은 Micrometer-facing dot.case naming과 seconds/bytes/total suffix vocabulary를 framework dependency 없이 보존한다. CardinalityBounds는 bounded tag의 상한을 Java mirror로 제공하고, ForbiddenMetricTags는 request_id/user_id/raw URL/query/header/IP 같은 unbounded source를 metric label에서 금지한다. request_id가 baggage에는 허용되지만 metric label에는 금지되는 비대칭은 test에서 의도적으로 pin 되어 있다.

TraceParent는 이 skeleton이 지원하는 strict v00 subset을 parse/render한다. lowercase hex, non-zero trace/span id, 2-byte flags를 검사하고 wrong version을 거부한다. BaggageAllowlisttenant_id, request_id만 보존하는 단순 parse/filter/render utility다. 이는 full W3C baggage grammar validator라기보다 propagation boundary allowlist다. SpanErrorRecorder.NOOP은 tracer library가 없는 기본 template에서도 outer adapter가 동일 seam을 호출할 수 있게 한다.

Domain context propagation

DomainContextPropagator는 diagnostic MDC와 분리된 domain/business context channel이다. default ThreadLocalDomainContextPropagator는 plain ThreadLocal을 쓰고 implicit inheritance를 금지하며 capture()/restore()wrap()으로 명시적 hand-off를 수행한다. virtual-thread test는 wrap을 썼을 때 전달되고 쓰지 않았을 때 상속되지 않으며 scope close 뒤 worker context가 복원되는 것을 검증한다.

이 seam은 문서상 계획에 그치지 않는다. production reachability 검색에서 app-bootstrapDomainContextConfig, AsyncContextTaskDecorator, AsyncExecutorConfig, persistence-jpa audit adapter, sample composition config가 실제로 소비하는 것이 확인됐다.

DomainContextKey equality/hash는 name only이고 read 시 요청 key의 Class<T>로 cast한다. 동일 이름의 서로 다른 type key를 만들면 같은 slot을 공유할 수 있고 잘못된 type으로 읽을 때 ClassCastException 가능성이 있다. source javadoc이 name-only identity를 명시하므로 hidden implementation bug로 단정하지 않지만, 현재 test는 same-name/different-type collision을 pin 하지 않는다. P1 contract-hardening 후보로 남긴다.

Operational record store

OperationalRecordStorePort는 durable operational state를 특정 inbound/outbound adapter에 종속시키지 않는 neutral CAS port다. record version 0은 absent를 뜻하며 compareAndSet/compareAndRemove의 expectedVersion이 lost update 방지 evidence 역할을 한다. GraphQL persisted-operation adapter가 이 port를 실제 production dependency로 사용하며, durable provider implementation 자체는 해당 inbound adapter에 들어있지 않다.

OperationalRecord는 namespace/key non-blank와 version >= 0은 강제하지만 javadoc의 "bounded"라는 표현에 대응하는 길이/character limit은 source에 없다. 이는 문서와 constructor enforcement 강도의 차이이며 P2 확인 후보로 남긴다.

Activation and health snapshot

MasterSwitchParser는 unset=false, true/false case-insensitive만 허용하며 yes, 1, on, whitespace-padded value를 invalid로 처리한다. canonical+legacy가 동시에 있으면 값이 같아도 ambiguous로 실패하고 legacy-only는 replacement property를 반환한다. 이는 operator configuration을 permissive coercion하지 않는 fail-closed contract다.

RedisHealthSnapshotProvider는 Redis client/connection/credential을 shared boundary로 새지 않게 role/capability/state/reason/semantic freshness만 projection한다. eviction policy는 runtime CONFIG 조회 증명이 아니라 configured expectation임을 enum 이름과 javadoc으로 명시한다.

Messaging envelope schema

contracts/messaging/envelope/v1.schema.json은 Draft 2020-12 schema resource이며 envelopeVersion/eventId/contractId/payloadVersion/logicalDestination/aggregate/occurredAt/correlationId/contentType/payload를 required로 고정하고 top-level/aggregate에 unevaluatedProperties:false를 둔다. checked-in SHA-256은 bf6f2e13fafe01b8ef4cbb73d7ba3f5703bfc68d145bdfe43190bf606dbd00b1이다.

MessagingEnvelopeSchemaResourceTest는 schema text 자체, identifier regex parity, Java int/long 경계 vector, strict UTF-8, exact digest를 JDK API로 검증한다. 이 테스트는 resource drift와 digest mismatch를 강하게 막지만 README가 명시하듯 실제 Draft 2020-12 validator interoperability나 broker runtime discovery를 증명하지는 않는다.

Reachability / wiring evidence

  • DomainContextPropagator: app-bootstrap composition + async decorator, persistence-jpa audit adapter, sample composition에서 production use 확인.
  • OperationalRecordStorePort: inbound GraphQL persisted-operation registry에서 production use 확인. 이 방향성은 adapter-specific storage interface를 outbound가 구현하는 역방향 dependency를 피한다.
  • registry상 shared-contract는 다른 production project를 의존하지 않는 leaf이며 app-bootstrap/sample-portfolio runtime membership을 가진다.
  • rate-limit, response, error 등의 세부 consumer 전체는 각 adapter/application bounded scope에서 추가 분석할 대상이며 이번 scope에서는 representative reachability와 contract 자체를 완전 읽기 대상으로 삼았다.

Verification

실제 실행 결과:

  • ./gradlew :shared-contract:test --console=plain → BUILD SUCCESSFUL, exit 0
  • ./gradlew :shared-contract:edgeRateLimitContractTest --console=plain → BUILD SUCCESSFUL, exit 0
  • source revision 확인: a24ece9cf797f7ea647e33bf846b115208ed1ba5
  • git status --short → output 없음, working tree clean

Coverage ledger

분모는 src/main 전체 파일, src/test 전체 파일, custom edgeRateLimitContractTest source, 그리고 module-level CLAUDE.md, README.md, build.gradle이다.

  • FULL_READ: 82
    • main production/resource 55
    • unit/contract test 23
    • edgeRateLimitContractTest 1
    • module policy/rationale/build 3
  • STRUCTURAL_ONLY: 4
    • production placeholder .gitkeep 3
    • test placeholder .gitkeep 1
  • EXCLUDED: 0
  • UNCLASSIFIED: 0

따라서 selected bounded scope는 completion standard를 충족한다.

Open questions / improvement backlog

P1 — response/LRO invariant enforcement boundary

Envelope, BulkEnvelope, Operation, PageMeta의 문서상 valid shape가 factory tests에는 고정되어 있지만 public canonical constructor에서 강제되지 않는다. raw constructor 사용이 실제로 허용된 extension surface인지, 아니면 constructor-level validation으로 invalid state를 막아야 하는지 결정이 필요하다.

P1 — DomainContextKey same-name different-type collision

key identity가 name only인 반면 retrieval은 requested type cast를 수행한다. 동일 name의 다른 Class<T> key를 선언하는 것이 forbidden contract라면 creation-time collision 방지 또는 registry rule/test가 필요하고, 의도적으로 허용한다면 failure semantics를 문서화할 필요가 있다.

P2 — bounded operational record identifiers

OperationalRecord javadoc은 namespace/key를 bounded라고 설명하지만 constructor는 blank 여부만 확인한다. provider key size/character-set 제한을 shared contract가 소유해야 하는지 확인이 필요하다.

P2 — permission component grammar

permission은 colon segment 수, blank, normalization은 강제하지만 segment character grammar는 제한하지 않는다. registry SSOT가 더 좁은 grammar를 요구한다면 shared value object와 parity test가 필요하다.

P2 — messaging schema qualification boundary

현재 JDK-only test는 exact resource/digest/selected semantic vectors를 검증한다. Draft 2020-12 validator 호환성은 별도 qualification evidence가 필요하며 현재 module test 성공만으로 이를 주장해서는 안 된다.

다음 scope

queue의 동일 active project를 유지하고 다음 PENDING scope인 application-core를 다음 실행에서 분석한다.

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 40개 (main 35 · test 2 · 기타 3).

src/shared-contract/build.gradle
src/config/architecture/modules.json                    (shared-contract 항목)

main:
  src/main/java/dev/caskeleton/shared/activation/MasterSwitchParser.java
  src/main/java/dev/caskeleton/shared/concurrency/DomainContextKey.java
  src/main/java/dev/caskeleton/shared/concurrency/DomainContextPropagator.java
  src/main/java/dev/caskeleton/shared/concurrency/ThreadLocalDomainContextPropagator.java
  src/main/java/dev/caskeleton/shared/error/AdapterDisabledException.java
  src/main/java/dev/caskeleton/shared/error/ApiErrorCarrier.java
  src/main/java/dev/caskeleton/shared/error/ApiErrorCode.java
  src/main/java/dev/caskeleton/shared/error/Category.java
  src/main/java/dev/caskeleton/shared/error/DependencyFailureException.java
  src/main/java/dev/caskeleton/shared/error/OperationalError.java
  src/main/java/dev/caskeleton/shared/error/PersistenceFailureException.java
  src/main/java/dev/caskeleton/shared/health/RedisHealthSnapshotProvider.java
  src/main/java/dev/caskeleton/shared/metrics/CardinalityBounds.java
  src/main/java/dev/caskeleton/shared/metrics/ForbiddenMetricTags.java
  src/main/java/dev/caskeleton/shared/metrics/MetricNaming.java
  src/main/java/dev/caskeleton/shared/operation/Operation.java
  src/main/java/dev/caskeleton/shared/opstore/OperationalRecord.java
  src/main/java/dev/caskeleton/shared/opstore/OperationalRecordStorePort.java
  src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitSubject.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateLimitDecision.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicy.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateLimitOutcome.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateLimitPolicy.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateLimitRequest.java
  src/main/java/dev/caskeleton/shared/ratelimit/RateParameters.java
  src/main/java/dev/caskeleton/shared/request/Patch.java
  src/main/java/dev/caskeleton/shared/response/BulkEnvelope.java
  src/main/java/dev/caskeleton/shared/response/Envelope.java
  src/main/java/dev/caskeleton/shared/response/PageMeta.java
  src/main/java/dev/caskeleton/shared/response/ResponseMeta.java
  src/main/java/dev/caskeleton/shared/security/Permission.java
  src/main/java/dev/caskeleton/shared/tracing/BaggageAllowlist.java
  src/main/java/dev/caskeleton/shared/tracing/SpanErrorRecorder.java
  src/main/java/dev/caskeleton/shared/tracing/TraceParent.java
  src/main/resources/contracts/messaging/envelope/v1.schema.json

test:
  src/test/java/dev/caskeleton/shared/contract/messaging/MessagingEnvelopeSchemaResourceTest.java
  src/test/java/dev/caskeleton/shared/error/OperationalErrorTest.java

기타:
  CLAUDE.md
  README.md
  src/build.gradle


A03. application-core

분석 중에는 03-application-core.md 파일이었다. 454줄.

application-core 상세 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: application-core
  • canonical state analysisFile: §A03 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/application-core · Gradle :application-core
  • registry allowed_dependencies: ["domain-core", "shared-contract"]
  • registry runtime_memberships: ["app-bootstrap", "sample-portfolio"]
  • coverage ledger: FULL_READ 1021 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


Status: COMPLETE
Source revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
Analysis cycle: 1 (normal)

1. 분석 범위와 완료 기준

application-coresrc/application-core 하나의 Gradle leaf이지만 작은 use-case 모듈이 아니다. framework-free inbound use-case 계약, transaction/idempotency/outbox/inbox, cache/lease/lock, durable operation, realtime, object/file publication, fileserver, notification까지 애플리케이션 정책과 outbound port를 폭넓게 소유한다.

이번 분석의 source denominator는 src/application-core/src 아래 Java source 전부다. src/main 885개, src/test 136개로 총 1,021개이며 resource/non-Java source는 없다. build/의 generated output은 source coverage에서 제외했다. CLAUDE.md, README.md, build.gradle, downstream adapter/bootstrap/architecture-test source는 별도 source anchor로 읽었으며 1,021개 denominator에는 포함하지 않는다.

top-level package production test disposition
cache 29 5 FULL_READ
capability 3 1 FULL_READ
command 1 0 FULL_READ
fileexport 2 0 FULL_READ
filepublication 16 1 FULL_READ
fileserver 166 38 FULL_READ
idempotency 34 6 FULL_READ
inbox 9 1 FULL_READ
lease 14 2 FULL_READ
lock 3 2 FULL_READ
messaging 13 3 FULL_READ
notification 415 51 FULL_READ
objectstorage 88 5 FULL_READ
observability 2 1 FULL_READ
operation 11 2 FULL_READ
outbound 1 1 FULL_READ
outbox 28 9 FULL_READ
query 1 0 FULL_READ
realtime 14 0 FULL_READ
security 7 2 FULL_READ
storage 8 0 FULL_READ
transaction 17 5 FULL_READ
usecase 3 1 FULL_READ
합계 885 136 FULL_READ 1,021 / UNCLASSIFIED 0

따라서 이 문서에서 COMPLETE는 “대표 파일을 샘플링했다”는 뜻이 아니라 source denominator 전체를 읽고 package별 contract/invariant/test/wiring을 분류했다는 뜻이다.

2. 모듈 경계와 빌드 의존성

Observed. build.gradle의 production project dependency는 :shared-contract 하나뿐이다. application-core가 Spring, JPA, Redis, Kafka, filesystem provider 같은 구현 모듈을 직접 참조하지 않고, 외부 구현은 composition root와 adapter가 역으로 이 모듈의 port를 구현한다.

Observed. CommandUseCase<C extends Command,R>QueryUseCase<Q extends Query,R>UseCase<I,O>.handle(I)를 write/read intent에 맞게 타입으로 좁힌다. 자체적으로 transaction을 열거나 security interceptor를 실행하지 않는다. 실행 정책은 @UseCaseCapability에 별도로 선언된다.

Observed. @UseCaseCapability는 runtime TYPE annotation이며 transactionMode, idempotency, repositoryAccess를 필수로 받고 externalOutboundAllowed, sensitiveRead, bulkWrite, crossTenantAdmin을 추가 선언한다. annotation 자체는 metadata에 불과하지만 CleanArchitectureTest가 concrete Command/Query use case에 annotation 존재를 강제한다.

Observed. architecture fitness function은 다음 coherence를 직접 검사한다.

  • READ_ONLY + READ_REPOSITORYTransactionPort.inRead를 직접 호출해야 한다.
  • WRITE + WRITE_REPOSITORYinWrite 또는 inRootWrite를 직접 호출해야 한다.
  • REQUIRES_NEWinNew를 직접 호출해야 한다.
  • repositoryAccess != WRITE_REPOSITORY인 use case가 repository write verb를 직접 호출하면 실패한다.
  • bulkWrite=trueWRITE_REPOSITORY를 요구한다.
  • mutating use case는 type-level @RequiresPermission을 선언해야 한다.
  • application/domain은 Spring Security에 의존할 수 없다.

이 enforcement에는 의도적으로 한계가 있다. ArchUnit의 direct-call 분석이므로 helper 뒤에 숨은 repository mutation/transaction call은 잡지 못하고, AOP self-invocation/non-bean path도 static rule만으로 보장하지 않는다. 이 제한은 테스트 설명 자체에 명시돼 있어 최종 계약의 일부로 봐야 한다.

3. authorization: permission과 object access를 분리한다

AuthorizationPort는 principal의 raw role/permission을 기준으로 “이 종류의 작업을 수행할 수 있는가”를 판정하는 framework-free PEP다. AuthorizationPrincipal은 role set을 defensive copy + unmodifiable로 만들고 null roles는 empty set으로 정규화한다. AuthorizationDeniedException은 Spring AccessDeniedException 대신 application-owned failure를 사용한다.

object-level access는 별도 ObjectAccessPolicy가 담당한다. 같은 permission을 가진 사용자라도 ownership, membership, workflow state에 따라 특정 object 접근 결과가 달라질 수 있기 때문이다. ObjectAccessDecision은 denial에 stable code를 요구하고 hideExistence를 별도 boolean으로 보존해 transport가 403/404 disclosure 정책을 추측하지 않게 한다.

Historical evidence. ObjectAccessPolicyTest에는 이 계약이 과거 inbound GraphQL adapter에 있었고 GraphQL request context를 signature에 포함해 application-core가 구현하려면 transport에 역의존해야 했던 문제가 기록돼 있다. 현재 regression test는 policy/request/decision signature에 dev.caskeleton.adapter.* 타입이 다시 등장하면 실패한다. 이 프로젝트에서 “여러 호출자가 공유해야 하는 계약을 inbound adapter가 소유하면 Core가 Adapter에 의존하게 된다”는 문제가 실제로 있었던 근거다.

decideAll()의 default는 요청 순서를 보존하지만 object마다 decide()를 호출한다. set-based authorization을 제공하는 구현체가 override하지 않으면 batched loading 안에서 authorization N+1을 다시 만들 수 있다는 제한도 계약에 명시돼 있다.

4. transaction: framework vocabulary 대신 application semantic policy

TransactionPortinWrite, inRootWrite, inRead, inNew 네 개의 framework-neutral boundary를 노출한다. PolicyTransactionPort는 기존 surface를 깨지 않고 TransactionRequest -> TransactionResult 정책 기반 API를 추가한다.

TransactionPolicyId는 Spring propagation 숫자가 아니라 COMMAND_DEFAULT, COMMAND_SERIALIZABLE_REPLAY_SAFE, QUERY_PRIMARY, QUERY_REPLICA_ELIGIBLE, OUTBOX_APPEND, INBOX_AND_HANDLER, MAINTENANCE_NEW처럼 application semantic ID를 노출한다. TransactionRequest constructor는 read policy의 consistency allowlist, non-read의 readConsistency 금지, operationId-required policy의 stable id 존재를 fail-fast한다.

TransactionResult는 commit 결과를 다섯 상태로 분리한다.

  • Committed: physical commit을 확인한 결과.
  • Participating: outer transaction에 참여했지만 아직 commit을 주장할 수 없는 결과.
  • DeterminateRollback: rollback이 확정된 실패.
  • Indeterminate: commit 여부를 확정할 수 없는 결과.
  • CommittedWithPostCommitFailure: commit은 됐지만 이후 operational cleanup이 실패한 결과.

이 algebra의 핵심은 “exception이 발생했다 = rollback”으로 단순화하지 않는 것이다. 특히 Indeterminate는 last observed transaction phase와 optional reconciliation reference를 보존하며, CompletionResolutionSTILL_UNKNOWN을 정식 상태로 둔다. 불확실한 commit을 임의로 NOT_COMMITTED로 가정해 use case를 재실행하는 것을 피한다.

OperationId는 caller-owned opaque identity이며 1128 printable non-whitespace ASCII로 제한된다. ReconciliationReference도 1256으로 bounded/sanitized된다.

Historical evidence. TransactionCompletionResolver는 과거 JPA transaction engine 옆에 있었지만 실제 commit 증거(고유 제약, business row, idempotency row, outbox row)를 해석하는 주체는 application/domain이어서 dependency direction이 뒤집히는 문제가 있었다. 현재 SPI는 application-core에 있고 adapter가 이 계약에 의존한다. resolver는 evidence read만 해야 하며 original use case 재실행을 금지한다.

IrreversibleSideEffectContext 역시 persistence 쪽에서 application-core로 이동했다. use case가 email/payment/broker/object-storage 같은 rollback 불가능한 effect 직전에 mark()해야 retry coordinator가 해당 attempt를 재실행하지 않을 수 있기 때문이다. ThreadLocal marker는 in-transaction external I/O를 권장하는 장치가 아니라 아직 제거되지 못한 side effect에서 unsafe retry를 차단하는 fence다.

4.1 Spring/JPA 구현까지 추적한 결과

SpringTransactionPortPolicyTransactionPort를 구현한다. write/read/root-write는 REQUIRED, independent write는 REQUIRES_NEW이며 모든 legacy template은 provider default가 아니라 READ_COMMITTED를 명시적으로 pin한다. read template만 read-only다.

inRootWriteTransactionSynchronizationManager.isActualTransactionActive()를 transaction manager나 action 호출 전에 검사한다. focused adapter test는 ambient transaction이 있으면 action도 transaction manager도 호출되지 않음을 증명한다. 또 action 반환값은 physical commit 이후에만 caller에게 반환되고 commit failure 시 caller-visible result가 publish되지 않음을 검증한다.

Isolation enum에는 READ_COMMITTED/REPEATABLE_READ/SERIALIZABLE이 있지만 IsolationTest는 stricter level routing이 아직 planned라고 명시한다. 따라서 enum vocabulary가 존재한다는 사실을 “현재 legacy TransactionPort에서 세 isolation을 선택할 수 있다”로 확대 해석하면 안 된다.

5. idempotency, inbox, outbox: uncertainty를 상태로 보존한다

5.1 idempotency

초기 contract는 scope + request fingerprint로 claim/replay를 제공하고, same key/different fingerprint를 conflict로 분리한다. completed result는 replay하고 in-flight는 bounded poll한다. 이 버전은 “DB operation의 효과가 이미 발생했지만 응답만 잃은 상태”를 충분히 표현하지 못한다.

V2는 owner-safe CAS handle에 scope/token/attempt/revision/claimOperationId를 넣고 stale owner mutation을 거부한다. processing-start를 durable하게 확인하기 전에는 body를 실행하지 않으며 claim/start/completion의 unknown result는 inspect/reconcile 대상으로 남긴다. processing start 이후 ordinary RuntimeException은 효과가 없다고 증명할 수 없으므로 EFFECT_UNKNOWN_ABANDONED 쪽으로 분류되고 자동 replay 권한을 주지 않는다. 명시적인 RetryableNoEffect만 안전 재시도 근거로 취급한다.

scope digest는 versioned keyed digest + operation code로 정규화되고 raw identity는 외부 surface에서 제거된다. lease/replay TTL과 owner token grammar도 bounded다.

Historical evidence. V2 contract가 인접한 package에 중복 복제돼 구현체들이 서로 다른 nominal type을 참조한 문제가 있었고, singular contract를 유지하는 regression test가 존재한다.

5.2 inbox

Inbox contract는 same-store 처리와 owner-safe receive/process state를 모델링한다. RECEIVED -> PROCESSING -> COMPLETED/RETRYABLE/DEAD 상태를 가지고 ACK는 handler transaction commit 이후에만 가능하다. expired owner가 늦게 결과를 기록하는 것을 owner token/attempt/revision/operation identity로 막는다. acquire/processing uncertainty 역시 provider-neutral typed outcome으로 보존한다.

5.3 outbox

Outbox append는 caller write transaction에 참여하고 broker publish는 transaction 밖에서 수행한다. claim/status transition만 짧은 transaction으로 분리한다. publish outcome은 accepted/ambiguous/rejected/exception을 구분한다.

payload validator는 append boundary에서 poison event를 미리 차단한다. legacy JSON payload는 256 KiB, depth 64 등 bounded parser contract를 갖고 control/trailing content를 거부한다. 과거 relay에서야 invalid payload를 발견해 batch를 독성화하던 문제가 이 boundary 이동의 근거다.

accepted 후 markPublished가 실패하면 row는 IN_FLIGHT에 남고 lease expiry 뒤 reclaim되어 중복 publish가 실제로 가능하다. 테스트는 이 duplicate window를 숨기지 않고 증명한다. 따라서 이 outbox는 exactly-once가 아니라 at-least-once + downstream dedupe 모델이다.

V2는 immutable event version/ordinal, DB-authoritative receipt retention/publication epoch/dispatch authority를 추가한다. claim batch와 lease가 bounded되고 owner-safe CAS가 사용된다.

6. durable operation: process-local future 대신 durable state machine

DurableOperation은 PENDING/RUNNING/SUCCEEDED/FAILED/CANCELED/EXPIRED를 저장 가능한 state로 모델링한다. RUNNING은 lease가 필수이고 terminal state는 completion timestamp가 필수이며 terminal state가 lease를 유지할 수 없다. SUCCEEDED는 result reference, FAILED는 failure가 필수다.

DurableOperationStorePort의 running-state mutation은 worker identity를 받아 stale worker가 takeover 이후 result/progress를 기록하지 못하게 한다. expired lease는 reclaim되어 PENDING으로 돌아가고 TTL이 지난 nonterminal record는 EXPIRED 처리된다.

SubmitDurableOperationUseCase는 operation row와 outbox publish intent를 같은 inWrite transaction 안에 기록한다. broker를 직접 호출하지 않는다. 테스트는 outbox write failure 시 operation row도 rollback되어 둘 중 하나만 남는 상태를 막는 것을 검증하고, identical resubmission은 기존 operation을 반환하며 두 번째 outbox row를 만들지 않는 것을 검증한다.

7. cache, lease, lock: 동시성 완화와 correctness authority를 구분한다

7.1 cache

CacheAsideExecutor는 fresh/negative hit, hard miss, stale, incompatible schema, provider unavailable을 명시적으로 구분한다. source load에는 key-local single-flight와 global source bulkhead를 함께 적용한다. in-flight key 수, waiter 수, source concurrency, admission wait, load deadline이 모두 bounded다.

stale value는 hard expiry 이전이며 classified transient failure일 때만 fallback될 수 있다. permanent failure에는 stale을 반환하지 않는다. source load 중 invalidation이 발생하면 lookup 때 캡처한 CacheWriteCondition이 더 이상 일치하지 않아 이전 source result의 refill을 거부한다. 이는 invalidate 직후 늦게 끝난 source load가 stale value를 resurrect하는 race를 막는다.

optional distributed refresh coordination은 soft lease로 한 pod만 refresh하도록 하지만 correctness lock은 아니다. owner는 lease 획득 후 cache를 재확인해 다른 pod가 이미 fill했다면 source를 호출하지 않는다. claim 결과가 indeterminate이면 동일 attempt token으로 한 번만 재시도한다. contender는 stale이 아직 valid하면 즉시 stale을 반환할 수 있다.

CacheSingleFlight는 waiter timeout/interruption을 보존하고 완료된 flight를 제거한다. leader가 영원히 남아 key bound를 점유하지 않도록 monotonic deadline 이후 abandoned flight를 opportunistic reap한다.

7.2 distributed lease

V2 DistributedLeasePort는 caller가 provider send 전에 owner/operation token을 생성하고 acquire retry/inspection에서 동일 attempt를 유지하게 한다. response-loss uncertainty를 Indeterminate로 별도 표현한다. resource는 raw key가 아니라 versioned lowercase SHA-256 digest를 사용하고 toString은 token/digest를 redaction한다.

LeaseGuarantee는 명시적으로 EFFICIENCY_ONLY 하나다. 즉 generic lease는 duplicate work를 줄일 뿐 correctness-sensitive write를 authorize할 수 없다. LeaseWatchdog도 renewal failure/unknown이면 work cancellation과 loss signal을 한 번만 발생시키는 bounded scheduler일 뿐 process pause/Redis failover를 correctness guarantee로 감추지 않는다.

7.3 distributed lock

DistributedLockPort도 문서상 efficiency lock이다. finite wait와 crash-safety TTL을 갖지만 DB constraint 같은 correctness authority를 대체하지 않는다. lock release는 protected transaction commit 이후에 수행해야 한다. timeout은 shared OperationalError.LOCK_ACQUISITION_TIMEOUT으로 매핑 가능한 application exception으로 표현된다.

8. messaging과 realtime은 provider/transport vocabulary를 밖으로 밀어낸다

messaging application contract catalog는 contract id, logical destination, schema resource, ordering, payload/envelope bounds, sensitivity, retry/requeue horizon 등 semantic 정보만 가진다. Kafka topic/provider runtime type은 public contract에 없다. validated integration event는 partition key, schema/content hash, catalog/binding revision 같은 immutable evidence를 보존한다.

strict messagingApplicationContractQualificationTest는 normal test source set의 세 required class를 no-skip 조건으로 실행한다. 처음 digest property 없이 실행했을 때 prepareMessagingContractEvidence가 fail-closed로 거부했다. current source/archive, current application-core JAR, exact profile file의 SHA-256을 공급한 재실행에서는 15 tests, 0 skipped, BUILD SUCCESSFUL이었다. 즉 qualification은 단순 테스트 이름이 아니라 evidence provenance property까지 요구한다.

realtime contract는 durable fanout과 ephemeral fanout을 분리한다. durable은 accepted와 delivered를 동일시하지 않고 stream+position dedupe/replay를 모델링한다. stale cursor는 resnapshot 요구로 분리된다. presence는 non-authoritative이며 TTL/heartbeat failure 시 empty로 degrade할 뿐 security 판단에 사용하지 않는다. logical channel은 WebSocket/STOMP 같은 transport 명칭을 소유하지 않는다.

9. storage/file publication: legacy 경로와 semantic 경로가 공존한다

application.storage.ObjectStoragePort는 raw object key/whole-byte 방식의 legacy contract이며 forRemoval 표시가 있지만 실제 production consumer가 남아 있다. sample poster upload, adapter/config, characterization test에서 사용되므로 dead code로 분류할 수 없다. 제거 시점은 날짜가 아니라 실제 migration/zero usage로 판단하도록 문서화돼 있다.

fileexport 역시 raw filesystem path를 반환하는 opt-in legacy capability이며 FilesystemCsvExportAdapter/configuration을 통해 조건부 활성화된다.

반대로 filepublication은 logical destination, operation/reference/version, schema, row streaming/checkpoint, durability semantic을 provider-neutral 계약으로 만든다. CSV formula injection(=, +, -, @, tab, CR)을 reject하는 정책이 테스트로 고정돼 있고, raw Path/SFTP/fileserver 타입이 receipt surface에 나오지 않는다.

10. objectstorage: staged lifecycle, opaque identity, privilege separation

semantic objectstorage API는 provider/filesystem type을 노출하지 않는다. object identity/reference는 prefix + check digit를 포함한 opaque routed representation이고 redacted rendering을 제공한다. tampered/cross-prefix reference를 거부한다.

content I/O는 bounded pull/push callback context와 budget/cancellation/chunk contract를 사용하며 callback lifetime 밖에서 context를 재사용할 수 없다. zero-progress가 무한 loop로 이어지지 않도록 bounded 후 실패한다.

lifecycle은 staged -> verified -> published를 분리한다. scanner verdict는 exact stage/version/operation/policy revision에 결합되고 publish/cleanup mutation은 exact-version/fencing을 요구한다. scanner 권한과 purge 권한은 분리돼 검증 주체가 임의 삭제까지 할 수 없게 한다.

transient bearer grant는 URI/header를 redaction하고 TTL은 최대 24시간으로 제한한다. multipart part count는 1..10000이고 completion은 expected content identity를 요구한다. FullContentIdentity는 SHA-256 기반으로 ETag를 content identity로 오인하지 않는다.

11. fileserver: DB metadata와 physical content 사이의 실패 seam을 명시한다

fileserver는 application-core 안의 가장 큰 독립 orchestration 중 하나다. 핵심 contract는 “metadata transaction과 filesystem/object I/O가 원자적이지 않다”는 사실을 숨기지 않고 recovery model을 두는 것이다.

11.1 upload/write fencing

upload admission은 authorization을 quota/storage admission보다 먼저 수행해 denial이 side-effect-free이도록 한다. reservation metadata/session은 DB transaction에서 만들지만 staging physical object는 외부 작업이므로 실패 시 compensation/reconciliation 대상이 된다.

writer는 one-writer lease + fencing token을 사용한다. stale token은 append/finalize를 진행할 수 없고 takeover는 새 token을 만든다. append 시 metadata offset과 physical length가 다르면 자동 repair하지 않고 conflict로 중단한다.

finalize는 declared length, server-computed digest, optional client digest를 순서대로 검사한다. client digest는 server digest를 대체하지 않는다. 이후 VERIFYING으로 이동하고 verifier가 publish 승인해야 READY가 된다. READY가 유일한 public/downloadable state다.

publish physical success 뒤 READY metadata transaction이 실패하면 결과는 단순 retryable failure가 아니라 AmbiguousCompletionException과 recovery queue로 간다. physical publish가 이미 발생했을 수 있기 때문이다.

11.2 cleanup/recovery

cancel/cleanup race를 막기 위해 cleanup claim은 writer/cleaner barrier를 형성하며 stale cleanup claim을 reclaim하는 경로가 실제로 호출된다. physical delete 전에는 terminal state, lease, exact size/digest/metadata key를 재확인한다.

orphan reconciliation은 reference race를 다시 검사하고 retire/quarantine 후 physical purge를 분리한다. recovery 결과는 CONFIRMED_SUCCESS, NOT_APPLIED, RECOVERABLE_PARTIAL, QUARANTINE_REQUIRED, UNRESOLVED 등으로 unknown을 추측하지 않는다. READY인데 physical content가 없거나 digest가 불일치하면 정상으로 가장하지 않고 quarantine한다.

11.3 download/security/HTTP semantics

download authorization은 physical open보다 먼저 수행된다. HTTP precondition ordering을 명시하고 range 수를 최대 8개로 제한하며 overlap을 merge한다. malformed/unsatisfiable range는 typed 416 path로 분리되고 HEAD는 body를 열지 않는다.

stored-XSS 위험이 있는 HTML/SVG/XHTML/JavaScript 계열은 attachment로 강제하며 filename sanitizer는 path/control/bidi/Windows reserved-name을 처리한다. observability는 raw file/path/user id 대신 HMAC fingerprint와 low-cardinality dimensions를 사용한다.

12. notification: logical acceptance, provider uncertainty, callback reconciliation

notification은 application-core production 415개로 가장 큰 package다. public API, contact protection, routing/template, dispatch, callback, admin/operator plane까지 application-level semantics를 소유한다.

12.1 public API와 secret boundary

public contract는 arbitrary Object/Map<String,Object>를 허용하지 않고 sealed NotificationVariable algebra를 사용한다. 과거 mutable/arbitrary variable 때문에 serialization/fingerprint drift와 toString collision이 가능했던 것이 변경 근거다. structural test는 public API에 arbitrary Object가 다시 들어오지 않는지 검사하며, 과거 잘못된 test root로 vacuous pass했던 문제도 regression guard로 남아 있다.

NotificationPlan은 exact template version을 pin한다. recipient/metadata/variable count/depth가 bounded되어 있고 receipt는 “durable logical acceptance”이지 provider delivery를 의미하지 않는다.

contact point는 encrypted value + keyed fingerprint로 분리되고 protected contact rendering은 원문을 노출하지 않는다. template variable 자체에 reset token 같은 secret이 들어갈 수 있어 payload protection이 존재하며 decrypt 실패를 빈 값으로 degrade하지 않는다. contact lookup/provider request/callback fingerprint는 HMAC purpose를 분리해 동일 secret-purpose reuse를 피한다.

12.2 routing과 dispatch

routing은 explicit 또는 ordered fallback이며 parallel-first-success가 없다. 두 provider를 동시에 호출하면 irreversible duplicate를 만들 수 있기 때문이다. fallback도 앞 attempt가 ambiguous이면 차단된다.

NotificationDispatchService는 pre-call attempt state를 짧은 transaction에서 commit한 뒤 provider를 transaction 밖에서 호출하고, 결과를 두 번째 transaction에서 finalize한다. irreversible call 직전 lease ownership을 다시 검사하고 post-call transition은 lease generation으로 fencing한다.

provider response는 accepted/rejected/ambiguous를 구분한다. classified “provider call not started” failure는 NOT_SUBMITTED로 판단할 수 있지만 unclassified runtime exception은 effect가 발생했을 가능성을 버리지 않고 ambiguous로 간다. cancellation도 future attempt를 막을 뿐 provider 쪽 이미 발생한 effect를 undo했다고 주장하지 않는다.

canonical plan encoding은 versioned/length-framed 형식이고 map을 sort하며 fallback order까지 fingerprint semantics에 포함한다. 과거 canonical string을 JSON codec으로 다시 decode해 모든 dispatch가 실패하던 문제 때문에 writer와 dispatcher가 동일 canonical codec을 공유하도록 고정됐다.

12.3 callback/receipt

provider callback은 signature 검증을 위해 raw bytes를 보존하고 normalized headers를 제공하며 rendering은 body를 redaction한다. callback append와 projection 사이의 atomicity bug 때문에 durable callback write 후 projector 예외가 broker redelivery를 만들던 과거 경로가 제거되고 batch append contract가 명확해졌다.

provider request id가 callback보다 늦게 알려지는 경우를 위해 unmatched callback을 이후 attempt에 연결하는 late-match 경로가 있다. synthetic provider event fingerprint도 과거 사실상 attempt UUID만 반영해 event type이 달라도 dedupe될 수 있던 문제를 length-framed SHA-256 semantics로 교정했다.

12.4 확인된 P1 contract/implementation drift: admin atomic claim 미사용

Observed defect. AdminOperationStorePort.claim()의 javadoc은 과거 admin 경로가 find -> destructive side effect -> save여서 같은 operation id를 동시에 제시한 두 요청이 모두 “not found”를 보고 redrive를 두 번 실행할 수 있었음을 명시한다. 이를 막기 위해 command fingerprint를 포함한 atomic claim-before-effect 계약이 추가됐고 JpaAdminOperationStore도 DB-level claimOperation(...)을 구현한다.

그러나 현재 NotificationAdminApplicationService는 redrive/reconcile/suppress/provider-state 작업에서 여전히 operations.findByOperationId(...)를 먼저 읽고 side effect 이후 operations.save(...)한다. application-core notification production/test에서 operations.claim(...) 호출은 발견되지 않았다. 즉 저장소와 port에는 race fix가 구현돼 있지만 application service가 그 경로를 사용하지 않는다.

이것은 단순 미사용 API가 아니라 계약이 설명하는 동일 race가 service path에서 다시 열려 있는 drift다. 특히 concurrent same-operation-id redrive나 provider state/suppression에서 destructive/operator action이 중복 실행될 수 있다. 현재 application-core에는 이 race를 재현하는 admin concurrency test도 없다.

검증/수정 후보는 명확하다. service가 side effect 전에 command semantic fingerprint로 claim()하고, CLAIMED만 실행하며 replay/conflict/in-progress를 typed result로 반환하도록 바꾼 뒤 두 concurrent caller가 같은 operation id로 들어와도 side effect count가 정확히 1인지 regression test로 고정해야 한다. 이 분석에서는 source를 수정하지 않았다.

12.5 P2 hardening: derived idempotency key의 32-bit hash

AcceptNotificationApplicationUseCase.derivedKey()는 caller key가 없을 때 Integer.toHexString(Objects.hash(...))로 recipient/channel/template/version/variables를 축약한다. 이는 32-bit Java hash이므로 javadoc의 “서로 다른 요청은 collapse하지 않는다”는 표현을 수학적으로 보장하지 못한다.

다만 downstream submission logic은 동일 idempotency key의 canonical request fingerprint가 다르면 conflict로 분리하므로, 관찰된 구조상 collision의 주된 영향은 다른 요청이 조용히 같은 delivery로 합쳐지는 것보다 false idempotency conflict/availability failure에 가깝다. 따라서 P1 data corruption으로 확대하지 않고 P2 hardening으로 기록한다. canonical plan에 대한 cryptographic/keyed digest 또는 caller-supplied key를 우선하는 방향이 더 강한 계약이다.

13. 실제 production reachability와 legacy/dead-path 판정

static production reference scan에서 주요 application package는 모두 외부 production consumer를 확인했다.

package application-core 밖 production reference file 수
notification 156
objectstorage 96
fileserver 91
transaction 35
idempotency 30
usecase 23
security 22
filepublication 16
storage 16
outbox 15
messaging 14
realtime 12
outbound 11
observability 10
lock 4
operation 4
cache 2
fileexport 2
lease 2
inbox 1

이 count는 “모든 type이 각각 호출된다”는 의미가 아니라 package-level runtime/repository reachability의 evidence다. 세부 파일은 evidence/raw/013-application-core-reachability.txt에 보존했다.

legacy surface도 무조건 dead로 분류하지 않았다. application.storage.ObjectStoragePort, root notification NotificationPort, NotificationVariablesCodecPort, old idempotency-related exception 등은 adapter/config/characterization path에서 실제 reference가 남아 있다. 현재 상태는 dead code가 아니라 migration/compatibility surface다.

반대로 notification admin atomic claim()은 adapter 구현까지 존재하지만 application service consumer가 없는 unwired corrective path로 판정했다. 이것이 이번 scope의 가장 중요한 reachability finding이다.

14. 테스트 및 build-time verification

현재 snapshot에서 다음을 fresh 실행했다.

  1. ./gradlew :application-core:test --rerun-tasks
    결과: BUILD SUCCESSFUL, 14 tasks executed. application-core의 136 test source가 포함된 normal lane을 fresh 실행했다.

  2. :application-core:messagingApplicationContractQualificationTest --rerun-tasks
    첫 실행: digest properties 미제공으로 prepareMessagingContractEvidence가 fail-closed.
    재실행: source=git archive HEAD SHA-256, current application-core JAR SHA-256, exact profile bytes SHA-256을 명시.
    결과: 15 tests, 0 skipped, BUILD SUCCESSFUL.

  3. ./gradlew :app-bootstrap:test --tests dev.caskeleton.bootstrap.architecture.CleanArchitectureTest --rerun-tasks
    결과: BUILD SUCCESSFUL, 98 actionable tasks executed. capability/transaction/repository/security dependency fitness rules를 fresh 실행했다.

  4. ./gradlew verifyCleanArchitectureDependencies
    결과: BUILD SUCCESSFUL. module-registry allowlist와 실제 project dependency edge 검증이 통과했다.

qualification에 사용한 source digest는 e52b60c97a9496a0b18dc03f9232c295da37de7c0f56e692d000660eef0e370a, artifact digest는 6fd061854ad9a8631b65f5940f2a0792241d688aa4c11efcf392662109965942, profile hash는 7ac987233951a4d0427ac9e731a20df3a1c4aa1c15f30a5ed2b1e6c280b72afd였다. 이 값은 release-wide provenance를 주장하기 위한 것이 아니라 이 분석에서 실행한 application-core qualification 입력을 재현하기 위한 evidence다.

15. 주요 역사적 회귀 근거

현재 코드 형태의 이유를 source가 직접 설명하는 사례가 여러 개 확인됐다.

현재 형태 source가 기록한 과거 문제
ObjectAccessPolicy가 application-core 소유 GraphQL adapter-owned contract가 transport 역의존을 만들었음
TransactionCompletionResolver가 application-core 소유 JPA 옆 SPI를 domain이 구현하려면 adapter에 역의존해야 했음
IrreversibleSideEffectContext가 application-core 소유 use case가 persistence adapter를 import해야 marker를 호출할 수 있었음
outbox payload append-boundary validation relay에서 poison payload를 늦게 발견해 batch를 망가뜨릴 수 있었음
V2 idempotency singular contract 인접 duplicate nominal contract가 서로 다른 구현 타입을 만들었음
fileserver fenced writer/cleanup/recovery cancel/cleanup/writer race와 ambiguous physical/metadata seam
notification canonical codec canonical payload를 JSON codec으로 읽어 dispatch가 실패하던 path
notification owner+generation fencing expired worker가 renewal/write를 계속할 수 있던 race
callback append/project separation durable append 뒤 projector failure가 redelivery를 유발하던 atomicity 문제
admin claim() 계약 find-before-side-effect race로 동일 destructive operation이 동시에 두 번 실행될 수 있었음

따라서 application-core의 복잡성 상당 부분은 단순 추상화 선호가 아니라 failure/uncertainty/concurrency를 provider 구현보다 안쪽의 semantic contract로 끌어올린 결과로 관찰된다.

16. Findings / improvement backlog

P1 — notification admin atomic claim contract가 service에서 사용되지 않음
  • Fact: AdminOperationStorePort.claim()JpaAdminOperationStore.claim()은 존재하지만 NotificationAdminApplicationService는 redrive/reconcile/suppress/provider-state에서 findByOperationId -> side effect -> save를 사용한다.
  • Why it matters: 동일 operation id의 concurrent 요청이 둘 다 side effect를 실행할 수 있으며, 이는 claim javadoc이 명시한 과거 race와 동일하다.
  • Verification: 동일 operation id/command를 barrier로 동시에 호출하고 destructive action invocation count가 1인지 검증하는 concurrency regression test.
  • Candidate direction: service가 command fingerprint를 계산해 atomic claim을 먼저 수행하고 claimed/replay/conflict/in-progress를 분기.
  • Tech-Log: CASE + OPEN QUESTION/DECISION 후보.
P2 — notification derived idempotency key가 32-bit hash
  • Fact: fallback key는 Integer.toHexString(Objects.hash(...))다.
  • Why it matters: 서로 다른 request의 collision을 배제할 수 없어 javadoc의 강한 uniqueness 표현과 실제 guarantee가 맞지 않는다. canonical fingerprint 비교가 있으므로 silent convergence보다는 false conflict 위험이 중심이다.
  • Verification: known Java hash collision fixture 또는 property search로 distinct canonical request가 같은 derived key를 만들 수 있음을 확인하고 downstream conflict behavior를 고정.
  • Candidate direction: canonical plan에 대한 SHA-256/HMAC 계열 digest.
  • Tech-Log: OPEN QUESTION/REFERENCE 후보.
P2 — legacy storage/notification compatibility surface의 제거 조건 추적
  • Fact: deprecated/legacy 계약이 production wiring에 여전히 사용된다.
  • Why it matters: 이름만 보고 dead로 삭제할 수 없고 adapter/runtime migration이 먼저 끝나야 한다.
  • Verification: external production reference 0 + characterization replacement + config path removal을 migration gate로 사용.
  • Tech-Log: DECISION/REFERENCE 후보.
P3 — isolation vocabulary와 legacy routing capability의 시차
  • Fact: Isolation에는 stricter levels가 있지만 legacy TransactionPort template은 READ_COMMITTED로 고정되고 test도 stricter routing을 planned라고 명시한다.
  • Why it matters: public vocabulary만 보고 이미 지원되는 capability로 오해할 수 있다.
  • Verification: future routing이 추가될 때 use-case policy -> adapter transaction definition test를 함께 추가.
  • Tech-Log: OPEN QUESTION 후보.

17. 분석 한계

이 scope의 1,021 source/test 파일은 전부 읽었지만 모든 downstream adapter 내부 구현을 exhaustive 분석한 것은 아니다. application-core 계약의 실제 reachability/중요 semantic을 검증하는 데 필요한 adapter/bootstrap/architecture source만 cross-scope evidence로 추적했다. JPA, Redis, messaging provider, objectstorage/fileserver adapters 자체의 전체 implementation detail은 각 후속 bounded scope에서 다시 exhaustive 분석해야 한다.

CleanArchitectureTest가 통과해도 helper indirect call, reflection/string lookup, AOP self-invocation 같은 static-analysis blind spot은 남는다. 또한 in-memory/fake contract test가 실제 provider failure model 전체를 증명하지는 않는다. 이 한계는 관련 adapter scope에서 real engine/runtime evidence로 보강해야 한다.

18. 완료 판정

application-core는 다음 gate를 충족했다.

  • quantified denominator: production 885 + test 136 = 1,021.
  • coverage: FULL_READ 1,021 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0.
  • 23 top-level package를 모두 account했다.
  • build dependency와 shared-contract-only production edge를 확인했다.
  • use-case/capability/transaction/security architecture fitness function을 추적했다.
  • 주요 transaction/idempotency/cache/lease/outbox/fileserver/notification uncertainty와 failure mechanics를 문서화했다.
  • external production reachability와 legacy/unwired path를 검사했다.
  • fresh normal tests, strict messaging qualification, architecture tests, dependency verification이 모두 통과했다.
  • historical regression 근거와 P1/P2/P3 backlog를 분리했다.
  • source revision은 분석 종료 시점까지 a24ece9cf797f7ea647e33bf846b115208ed1ba5이며 source working tree는 변경하지 않았다.

따라서 application-core bounded scope를 COMPLETE로 판정한다. 프로젝트 전체는 아직 후속 adapter/messaging/bootstrap scope가 남아 있으므로 clean-architecture-backend-template 자체는 계속 IN_PROGRESS다.

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 40개 (main 35 · test 2 · 기타 3).

src/application-core/build.gradle
src/config/architecture/modules.json                    (application-core 항목)

main:
  src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java
  src/main/java/dev/caskeleton/application/cache/CacheSingleFlight.java
  src/main/java/dev/caskeleton/application/cache/CacheWriteCondition.java
  src/main/java/dev/caskeleton/application/fileserver/api/error/AmbiguousCompletionException.java
  src/main/java/dev/caskeleton/application/lease/DistributedLeasePort.java
  src/main/java/dev/caskeleton/application/lease/LeaseGuarantee.java
  src/main/java/dev/caskeleton/application/lease/LeaseWatchdog.java
  src/main/java/dev/caskeleton/application/lock/DistributedLockPort.java
  src/main/java/dev/caskeleton/application/notification/NotificationPort.java
  src/main/java/dev/caskeleton/application/notification/platform/admin/AdminOperationStorePort.java
  src/main/java/dev/caskeleton/application/notification/platform/admin/NotificationAdminApplicationService.java
  src/main/java/dev/caskeleton/application/notification/platform/api/NotificationPlan.java
  src/main/java/dev/caskeleton/application/notification/platform/api/NotificationVariable.java
  src/main/java/dev/caskeleton/application/notification/platform/dispatch/AcceptNotificationApplicationUseCase.java
  src/main/java/dev/caskeleton/application/notification/platform/dispatch/NotificationDispatchService.java
  src/main/java/dev/caskeleton/application/notification/platform/dispatch/NotificationVariablesCodecPort.java
  src/main/java/dev/caskeleton/application/operation/DurableOperation.java
  src/main/java/dev/caskeleton/application/operation/DurableOperationStorePort.java
  src/main/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCase.java
  src/main/java/dev/caskeleton/application/security/AuthorizationDeniedException.java
  src/main/java/dev/caskeleton/application/security/AuthorizationPort.java
  src/main/java/dev/caskeleton/application/security/AuthorizationPrincipal.java
  src/main/java/dev/caskeleton/application/security/ObjectAccessDecision.java
  src/main/java/dev/caskeleton/application/security/ObjectAccessPolicy.java
  src/main/java/dev/caskeleton/application/transaction/CompletionResolution.java
  src/main/java/dev/caskeleton/application/transaction/IrreversibleSideEffectContext.java
  src/main/java/dev/caskeleton/application/transaction/Isolation.java
  src/main/java/dev/caskeleton/application/transaction/OperationId.java
  src/main/java/dev/caskeleton/application/transaction/PolicyTransactionPort.java
  src/main/java/dev/caskeleton/application/transaction/ReconciliationReference.java
  src/main/java/dev/caskeleton/application/transaction/TransactionCompletionResolver.java
  src/main/java/dev/caskeleton/application/transaction/TransactionPolicyId.java
  src/main/java/dev/caskeleton/application/transaction/TransactionPort.java
  src/main/java/dev/caskeleton/application/transaction/TransactionRequest.java
  src/main/java/dev/caskeleton/application/transaction/TransactionResult.java

test:
  src/test/java/dev/caskeleton/application/security/ObjectAccessPolicyTest.java
  src/test/java/dev/caskeleton/application/transaction/IsolationTest.java

기타:
  CLAUDE.md
  README.md
  src/build.gradle

해석되지 않은 인용 (1종) — 외부 타입·문서상 약칭 등:
  evidence/raw/013-application-core-reachability.txt


A04. adapter-outbound-support

분석 중에는 04-adapter-outbound-support.md 파일이었다. 703줄.

adapter-outbound-support 상세 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-outbound-support
  • canonical state analysisFile: §A04 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/outbound/support · Gradle :adapter:outbound:support
  • registry allowed_dependencies: ["domain-core", "application-core", "shared-contract"]
  • registry runtime_memberships: ["app-bootstrap"]
  • coverage ledger: FULL_READ 8 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


상태: COMPLETE
기준 revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
분석 범위: src/adapter/outbound/support
Gradle path: :adapter:outbound:support

0. 커버리지와 숫자 지도

이 leaf는 크기가 작다. 그래서 대표 파일을 샘플링하지 않고 leaf-owned source/build/document를 전부 읽고, 실제 consumer와 composition-root wiring을 별도 cross-scope evidence로 추적했다.

file group count disposition reason
production Java 4 FULL_READ leaf의 전체 production source
test Java 1 FULL_READ leaf의 전체 dedicated test source
build.gradle 1 FULL_READ 실제 compile dependency와 leaf build policy
README.md 1 FULL_READ 현재 코드와 대조해야 하는 설계 결정 문서
CLAUDE.md 1 FULL_READ 현재 leaf-local 분석/경계 지침
resources 0 FULL_READ main/test resource 없음
합계 8 FULL_READ 8 / UNCLASSIFIED 0 bounded scope complete denominator

추가 측정:

  • production Java: 4 files / 약 97 LOC
  • test Java: 1 file / 약 89 LOC
  • production package directories: 2
    • dev.caskeleton.adapter.outbound
    • dev.caskeleton.adapter.outbound.support
  • entities/tables/migrations: 없음
  • configuration properties: 없음
  • runtime membership registry: app-bootstrap
  • leaf-owned Spring configuration: OutboundSupportConfig

Raw inventory는 evidence/raw/015-adapter-outbound-support-inventory.txt에 보존했다.

1. 모듈의 정체와 경계

adapter-outbound-support는 application port를 구현하는 하나의 기술 adapter라기보다 여러 outbound adapter가 공유할 수 있는 기술적 보조 seam이다.

현재 production surface는 사실상 세 가지다.

  1. OutboundCorrelation

    • SLF4J MDC에서 correlation_id를 조회한다.
    • 값이 없거나 blank면 "unknown"을 반환한다.
  2. FailOpenDependencyLogger

    • optional/fail-open outbound 호출의 success/failure observation을 공통 포맷으로 기록한다.
    • success는 DEBUG, failure는 WARN이다.
  3. OutboundSupportConfig

    • FailOpenDependencyLogger default bean을 제공한다.
    • @ConditionalOnMissingBean으로 fork/application이 같은 타입을 override할 수 있게 한다.

package-info.java는 outbound adapter package의 개괄만 가진다.

1.1 허용 dependency와 실제 dependency는 다르다

src/config/architecture/modules.json은 support leaf가 다음 project dependency를 허용한다.

  • domain-core
  • application-core
  • shared-contract

그러나 현재 build.gradle과 fresh compileClasspath 결과를 보면 실제 project dependency는 0개다. 실제 compile dependency는 다음 외부 라이브러리뿐이다.

  • spring-boot-autoconfigure 4.0.8
  • slf4j-api 2.0.18

즉 registry의 allowed_dependencies는 가능한 최대 경계를 나타내고, 현재 source graph가 그 edge를 모두 사용한다는 뜻이 아니다. support는 현 snapshot에서 domain/application/shared 타입과도 결합하지 않는다.

이 구분은 Clean Architecture 설명에서도 중요하다. “Core 쪽 dependency가 허용된다”와 “현재 adapter가 Core에 실제로 의존한다”는 별개의 사실이다.

2. OutboundCorrelation: MDC lookup을 한 곳으로 모은 작은 seam

OutboundCorrelation.current()의 규칙은 단순하다.

MDC[correlation_id] != null && !blank
    -> 해당 값
otherwise
    -> "unknown"

docs/registries/mdc-keys.yamlcorrelation_id 계약은 다음을 선언한다.

  • source: inbound filter
  • type: ULID
  • required in: request/dependency/application
  • propagation: HTTP/async/message
  • metric tag 사용 금지(high cardinality)

OutboundCorrelation은 이 중 조회와 missing sentinel만 소유한다. ULID 형식 검증이나 생성/전파를 여기서 하지 않는다. 따라서 이 utility가 임의 문자열을 그대로 반환한다고 해서 곧바로 contract 위반이라고 볼 근거는 없다. canonicalization/validation은 upstream context owner의 책임으로 보인다.

Reachability

scope 밖 production code에서 OutboundCorrelation을 직접 참조하는 파일은 현재 0개다. 하지만 dead code는 아니다.

  • FailOpenDependencyLogger가 같은 leaf 내부에서 production consumer다.
  • messaging/notification tests는 MDC key를 맞추기 위해 이 상수를 직접 사용한다.

따라서 “external production reference 0”만으로 dead 판정을 하면 오탐이다. 이번에 추가된 negative-space rule이 요구하는 바로 그 사례다.

3. FailOpenDependencyLogger: 진단을 business outcome과 분리하려는 계약

3.1 성공과 실패 포맷

logSuccess(...)는 DEBUG로 다음 정보를 기록한다.

  • dependency_name
  • dependency_type
  • operation
  • outcome=SUCCESS
  • correlation_id

logFailure(...)는 WARN으로 다음을 추가한다.

  • outcome=FAILURE
  • error=<exception simple class>: <cause.getMessage()>

README와 javadoc은 WARN을 선택한 이유를 “optional fail-open dependency가 실패해도 core use case 자체는 성공했기 때문”이라고 설명한다.

이 logger 자체는 retry, recovery, fallback을 수행하지 않는다. 실패 정책을 결정하는 주체가 아니라 이미 결정된 fail-open outcome을 관측하는 기술 seam이다.

3.2 실제 production consumer

repository-wide production reference scan에서 support package를 직접 import하는 current production files는 네 개뿐이었다.

Messaging:

  • MessagingConfig
  • OutboundMessagePublisher

Notification:

  • NotificationConfig
  • FailOpenNotificationProvider

반대로 support README가 “공유 consumer”로 설명하는 cache-redis, httpclient는 Gradle dependency는 유지하지만 support production type을 직접 참조하지 않는다. 이 차이는 §8에서 별도로 다룬다.

4. Confirmed P1 — cause.getMessage() 때문에 PII-safe logging 계약이 성립하지 않는다

4.1 문서와 테스트가 주장하는 계약

support source와 README는 다음 취지의 강한 주장을 한다.

logger method가 body/recipient/payload를 받지 않기 때문에 PII가 log에 닿지 않는다.

FailOpenDependencyLoggerTest도 실패 로그에 email/body marker가 없음을 검사한다.

하지만 테스트 fixture의 exception은 단순히 "connection refused"다. 즉 PII marker는 logger에 들어가는 어떤 argument에도 존재하지 않는다. 이 테스트는 payload object가 직접 argument로 전달되지 않는다는 것만 확인할 뿐, exception message를 통한 leakage를 검사하지 않는다.

4.2 실제 logger input은 payload-free가 아니다

logFailure는 다음 값을 그대로 formatted message에 넣는다.

cause.getClass().getSimpleName() + ": " + cause.getMessage()

그리고 consumer SPI들은 exception message의 내용을 제한하지 않는다.

  • NotificationProvider.send(Notification) → arbitrary Exception
  • GoogleEmailClient.send(Notification) → arbitrary Exception
  • SlackClient.send(Notification) → arbitrary Exception
  • MessageBroker.send(OutboundMessage) → arbitrary Exception

특히 Notification contract는 recipient/body가 PII이며 logger에 전달하면 안 된다고 명시한다. 하지만 provider SDK/fork implementation이 recipient나 response/body 일부를 exception message에 넣는 것을 이 interface가 통제할 수 없다.

4.3 실행 재현

현재 compiled FailOpenDependencyLogger에 다음 exception을 전달하는 focused probe를 실행했다.

RuntimeException(
  "provider rejected recipient secret@gmail.com body=secret-body-content")

실제 formatted WARN에는 다음 문자열이 그대로 남았다.

error="RuntimeException: provider rejected recipient secret@gmail.com body=secret-body-content"

probe source와 output은 각각:

  • evidence/raw/021a-support-logger-pii-probe.java
  • evidence/raw/021-support-logger-pii-probe.txt

에 보존했다.

4.4 global masking도 이 보장을 복구하지 않는다

app-bootstrapLogMaskingPatterns는 방어 심층화로 다음과 같은 secret 형태를 mask한다.

  • password/secret/token/api-key 계열 key=value
  • Authorization credentials
  • standalone Bearer token

그러나 arbitrary email address나 free-form body PII를 일반적으로 제거하는 규칙은 없다. app-bootstrap README 자체도 regex masking을 보증이 아니라 defence-in-depth라고 설명한다.

따라서 현재 “logger signature 때문에 PII가 들어올 수 없다”는 1차 방어선 설명은 사실과 맞지 않는다.

4.5 영향과 수정 후보

우선순위: P1 (security/privacy contract)

가능한 방향은 두 가지다.

  1. 공통 logger가 raw cause.getMessage()를 기록하지 않고 exception type + bounded/stable error classification만 기록한다.
  2. raw cause message가 정말 필요한 일부 dependency만 별도의 sanitizer/classifier를 거쳐 명시적으로 허용한다.

어느 쪽이든 현재의 “arbitrary exception message를 공통 logger가 그대로 출력”하는 방식은 PII-safe라는 강한 계약과 양립하지 않는다.

Regression test는 exception message 자체에 email/body/token marker를 넣어 formatted log에 남지 않는지 검증해야 한다. 현재 테스트처럼 payload object만 logger argument에서 제외하는 것으로는 부족하다.

5. Confirmed P1 — notification consumer는 diagnostic failure를 authoritative failure로 바꿀 수 있다

이 finding은 support logger의 consumer semantics를 추적하면서 발견했다.

5.1 messaging은 이미 이 문제를 구분한다

OutboundMessagePublisher는 broker call과 observation을 분리한다.

broker.send
  -> success/failure fact 결정
  -> observeQuietly(logger...)

observeQuietly는 logger가 RuntimeException을 던져도 caller-visible broker outcome을 바꾸지 않는다.

source comment에는 과거 버그도 직접 기록돼 있다.

  • send와 success log가 같은 try block에 있었음
  • broker는 이미 메시지를 받았음
  • success logger가 실패함
  • 같은 catch가 이를 publish failure로 오인했음

현재 OutboundMessagePublisherTest.aLoggerFailureAfterAConfirmedSendIsNotAPublishFailure는 logger가 DEBUG에서 실제로 예외를 던지도록 만들고도 publish가 예외 없이 끝나며 broker send가 1회 완료됐음을 검증한다.

fresh focused test도 통과했다.

5.2 notification은 같은 shared logger를 다른 방식으로 사용한다

현재 FailOpenNotificationProvider는 다음 구조다.

try {
    delegate.send()
    logSuccess()
} catch (Exception ex) {
    logFailure(ex)
}

여기서는 provider outcome과 diagnostic outcome이 분리되지 않는다.

Case A — provider 성공 후 success logger 실패

delegate.send()가 성공한 뒤 logSuccess()가 RuntimeException을 던지면 같은 catch가 잡는다. 그 결과 이미 성공한 provider send에 대해 logFailure()까지 호출된다.

focused probe 결과:

SUCCESS_PATH sends=1 debugCalls=1 warnCalls=1

실제 send는 1회 성공했지만 success observation failure 때문에 WARN failure observation이 추가 호출됐다.

Case B — provider 실패 후 failure logger도 실패

provider failure가 catch된 뒤 logFailure()가 RuntimeException을 던지면 이를 흡수하는 바깥 경계가 없다.

probe 결과:

FAILURE_PATH propagated=IllegalStateException:logger-warn-failed warnCalls=1

즉 클래스가 “provider failure를 swallow해 core use case를 실패시키지 않는다”고 선언해도 diagnostics failure가 caller까지 전파될 수 있다.

probe source/output:

  • evidence/raw/022a-notification-logger-failure-probe.java
  • evidence/raw/022-notification-logger-failure-probe.txt
5.3 현재 notification test가 green인 이유

NotificationAdapterTest는 ordinary ListAppender를 사용한다. provider failure와 PII object가 log line에 직접 들어가지 않는 것은 검증하지만 logger/appender 자체가 실패하는 fixture는 없다.

fresh NotificationAdapterTest는 정상 통과했다. 따라서 이 finding은 “기존 테스트 실패”가 아니라 green test가 다루지 않는 failure seam이다.

우선순위: P1 (reliability / outcome correctness)

수정 후보:

  • messaging과 동일하게 provider call과 observation을 분리하고 observation failure를 non-authoritative로 흡수한다.
  • 또는 FailOpenDependencyLogger 자체를 no-throw contract로 바꿔 모든 consumer를 보호한다.

후자는 shared behavior를 바꾸므로 messaging/notification뿐 아니라 future consumer까지 contract review가 필요하다. 어느 owner가 isolation을 가져갈지는 후속 Decision 후보로 남긴다.

6. OutboundSupportConfig: unconditional shared bean seam과 실제 runtime wiring

OutboundSupportConfig@Configuration이며 FailOpenDependencyLogger bean 하나만 제공한다.

@ConditionalOnMissingBean
FailOpenDependencyLogger failOpenDependencyLogger()

별도 master property condition은 없다. 이는 support 자체를 optional capability로 취급하지 않고, 실제 provider/client capability의 on/off를 sibling adapter가 소유하게 하려는 구조다.

6.1 direct production reference 0이지만 unwired가 아니다

OutboundSupportConfig를 support 밖 production Java에서 명시적으로 참조하는 파일은 0개다. 그러나 실제 composition root CaSkeletonApplication은 다음 broad package를 component scan한다.

dev.caskeleton.adapter

AUTO_CONFIGURED_PACKAGES exclusion에는 messaging/notification/persistence 등은 들어가지만 support package는 포함되지 않는다. 따라서 support config는 broad component scan으로 도달한다.

registry도 support runtime membership을 app-bootstrap으로 선언하고 app-bootstrap/build.gradle이 support project를 직접 implementation한다.

따라서 이 configuration은 현재 active scanned path다.

6.2 conditional sibling comparison

support config 자체에는 @ConditionalOnProperty가 없고 @ConditionalOnMissingBean만 있다. 이것은 같은 optional adapter들의 master switch 누락으로 판정하지 않았다.

이유:

  • support는 provider/client를 생성하지 않는다.
  • logger bean 하나만 default로 제공한다.
  • actual messaging/notification/httpclient 등은 자기 capability root에서 activation을 소유한다.
  • support README와 config javadoc 모두 이 비대칭을 의도적으로 설명한다.

OptionalAdapterBeanGatingTest도 support config를 함께 넣은 상태에서 optional adapters가 기본 disabled여도 context가 성공함을 검증한다. 다만 이 test는 OutboundSupportConfig.withUserConfiguration(...)으로 직접 공급하므로 full-app component-scan evidence 자체는 아니다. full app wiring은 CaSkeletonApplication source와 registry/build edge가 별도 근거다.

7. Build / ArchUnit enforcement

7.1 registry

modules.json에서 support는 독립 leaf이며 runtime membership은 app-bootstrap이다.

7.2 Gradle dependency validation

fresh verifyCleanArchitectureDependencies가 통과했다. 이 task는 registry의 allowed dependency와 실제 Gradle project edge를 비교한다.

중요한 한계는 이 검증이 edge가 허용되는지를 판단한다는 점이다. 사용되지 않는 allowed edge까지 제거해야 한다고 판단하지는 않는다.

7.3 outbound peer isolation

CleanArchitectureTest.OUTBOUND_ADAPTERS_ARE_PEERS_SHARING_ONLY_SUPPORT는 outbound adapter family를 slice로 나누고 서로 직접 의존하지 못하게 한다.

유일한 shared-code 예외는 target package가:

..adapter.outbound.support..

인 dependency다.

따라서 messaging → notification 같은 peer coupling은 금지하지만 messaging → support는 허용한다.

fresh CleanArchitectureTest --rerun-tasks도 통과했다.

이 구조는 support 모듈이 단순 편의 library가 아니라 outbound family에서 sanctioned shared dependency point라는 점을 build-time fitness function으로 고정한다.

8. Negative-space probes

강화된 분석 규칙에 따라 네 가지 부재/중복/drift probe를 별도로 수행했다.

8.1 Public surface reachability

Raw evidence: 016-adapter-outbound-support-public-reachability.txt

public type support 밖 current reference 판정
FailOpenDependencyLogger messaging/notification production + tests active shared surface
OutboundCorrelation external production 0, downstream tests 존재 leaf-internal production utility, dead 아님
OutboundSupportConfig external production direct ref 0, app-bootstrap test ref 존재 component-scan active path

결론적으로 현재 세 타입 중 confirmed dead public type은 없다.

8.2 Conditional sibling comparison

Raw evidence: 017-adapter-outbound-support-conditional-wiring.txt

  • support config: unconditional configuration + missing-bean override seam
  • app composition root: support package는 broad component scan에 포함
  • optional provider adapter package들은 별도 conditional/auto-config ownership

support의 unconditional nature는 현재 역할과 일치하며 conditional mismatch defect로 판정하지 않았다.

8.3 Duplicate / competing mechanism sweep

Raw evidence: 018-adapter-outbound-support-duplicate-mechanisms.txt

확인한 주요 후보:

  • FailOpenDependencyLogger: current fail-open shared logger
  • Slf4jOutboxRelayFailureReportAdapter: durable/outbox failure reporter
  • cache Redis 내부의 자체 logger들
  • historical OutboundHttpDependencyLogger

현재 evidence로는 이들을 같은 runtime responsibility의 confirmed duplicate라고 볼 수 없다.

  • outbox reporter는 fail-closed durable relay의 typed report를 기록한다.
  • Redis logger는 lifecycle/config/SDK observability 역할이다.
  • HTTP dependency logger는 현재 source에서 제거됐다.

따라서 현재 중복 fail-open dependency logger 구현 defect는 확인되지 않았다.

다만 messaging과 notification이 동일 logger를 사용하면서 diagnostics-failure semantics가 다르다는 consumer-level inconsistency는 §5의 confirmed finding이다.

8.4 Documentation / measured-claim drift

Raw evidence: 019-adapter-outbound-support-document-drift.txt

여기서는 명확한 drift가 확인됐다.

Drift 1 — dependency SSOT 위치

README:

src/build.gradle 의 allowedProjectDependencies[...]가 SSOT

현재:

  • root AGENTS.md: src/config/architecture/modules.json가 SSOT
  • support CLAUDE.md: 동일
  • src/build.gradle은 registry를 읽고 allowedProjectDependencies map을 파생 생성

즉 variable 자체는 아직 존재하지만 source-of-truth 위치 설명은 outdated다.

Drift 2 — CLAUDE.md 부재 주장

README:

이 모듈은 아직 별도 CLAUDE.md를 두지 않았다

현재:

src/adapter/outbound/support/CLAUDE.md

가 실제 존재한다.

Drift 3 — 존재하지 않는 현재 비교 대상

README는 fail-open WARN logger와 OutboundHttpDependencyLogger를 현재 대비되는 구현처럼 설명한다.

current repository exact search에서는 이 symbol이 support README 한 줄 외에 존재하지 않는다.

Git history를 보면 해당 class는 초기 repository에 존재했으나 commit 5f10b791...에서 httpclient 관련 old classes/tests와 함께 삭제됐다. support README는 initial commit 이후 이 변화에 맞춰 갱신되지 않았다.

우선순위: P3 documentation maintenance

9. Candidate unnecessary Gradle edges — cache/httpclient → support

Raw evidence: 020-adapter-outbound-support-project-edge-usage.txt

다음 네 leaf는 모두 support를 implementation project(':adapter:outbound:support')로 선언한다.

  • cache-redis
  • httpclient
  • messaging
  • notification

하지만 current production Java reference는:

  • messaging: 있음
  • notification: 있음
  • cache-redis: 0
  • httpclient: 0

support leaf에는 resource도 없다. 따라서 cache/httpclient의 edge는 현재 source에서 직접 필요성을 찾지 못한 candidate stale dependency다.

다만 static textual reference만으로 Gradle edge가 100% 불필요하다고 단정하지 않는다. compile/runtime classpath presence 자체를 의도적으로 이용하는 plugin/reflection mechanism이 있는지 downstream leaf 전체 분석에서 다시 확인해야 한다.

우선순위: P3 cleanup candidate

검증 기준:

  1. 해당 leaf에서 support dependency 제거
  2. compile/test/runtime classpath 및 focused tests 실행
  3. app-bootstrap shipped composition/architecture tests 실행
  4. runtime bean graph 차이가 없는지 확인

현재 분석에서는 source를 수정하지 않았다.

10. 테스트 레인과 실제 증명 범위

10.1 support dedicated test

Fresh command:

./gradlew :adapter:outbound:support:test --rerun-tasks --console=plain

결과: BUILD SUCCESSFUL.

이 test가 실제로 증명하는 것:

  • normal Logback path에서 failure correlation id가 기록됨
  • MDC 없을 때 unknown sentinel
  • ordinary exception fixture에서 payload marker가 log에 없음
  • success DEBUG logging

증명하지 않는 것:

  • exception message에 PII가 있을 때의 안전성
  • logger/appender 자체 실패 시 consumer behavior
  • full app component scan
  • downstream provider semantics
10.2 messaging consumer test

Fresh OutboundMessagePublisherTest 통과.

이 class에는 logger가 success observation에서 실제 RuntimeException을 던지는 regression test가 있고, send outcome이 logger failure와 분리됨을 증명한다.

10.3 notification consumer test

Fresh NotificationAdapterTest 통과.

normal logger에서 provider failure를 swallow하고 direct Notification PII가 log line에 없음을 증명한다. throwing-logger case는 없다.

10.4 optional adapter gating

Fresh OptionalAdapterBeanGatingTest 통과.

support config와 여러 optional adapter configs를 ApplicationContextRunner에 함께 넣었을 때 disabled defaults가 실제 provider bean을 만들지 않는다는 것을 검증한다. full CaSkeletonApplication scan과 동일한 boot path는 아니다.

10.5 architecture suite / dependency registry
  • CleanArchitectureTest --rerun-tasks: BUILD SUCCESSFUL
  • verifyCleanArchitectureDependencies: BUILD SUCCESSFUL

이 둘은 source/package/project dependency constraint를 증명하며 diagnostics runtime failure나 PII behavior를 증명하지 않는다.

11. 역사적 형태

support README와 source는 initial repository부터 존재한다. 이후 architecture가 크게 성장하는 동안 support의 역할 설명 일부가 current implementation과 어긋났다.

특히 OutboundHttpDependencyLogger는 실제 historical class였다. 따라서 README의 해당 문장이 처음부터 허구였던 것은 아니다. 문제는 class가 삭제된 이후 문서가 함께 이동하지 않았다는 것이다.

또 messaging current source에는 logger failure를 business/publish failure와 분리하기 위해 observeQuietly가 도입된 과거 regression 설명이 남아 있다. 이 history는 notification consumer의 현재 shape와 비교할 때 중요한 evidence가 된다. 두 consumer가 같은 shared logger를 사용하지만 하나만 diagnostics를 non-authoritative로 격리한다.

12. Findings / improvement backlog

P1 — arbitrary exception message가 PII-safe logging boundary를 우회한다
  • Observed fact: FailOpenDependencyLogger.logFailure는 raw cause.getMessage()를 formatted WARN에 포함한다.
  • Runtime evidence: explicit email/body marker가 포함된 exception message가 실제 formatted log에 그대로 출력됐다.
  • Contract conflict: README/source/test는 payload/PII가 logger에 닿지 않는다고 주장한다.
  • Why it matters: fork/provider SDK exception message는 application이 통제하지 못하며 recipient/body/remote response를 포함할 수 있다.
  • Verification: 021a probe와 raw output, 향후 dedicated regression test.
  • Candidate: raw cause message 제거 또는 explicit sanitizer/classifier.
  • Tech-Log: CASE + DECISION 후보.
P1 — notification fail-open consumer가 logger failure를 격리하지 않는다
  • Observed fact: FailOpenNotificationProvider는 send와 logSuccess를 동일 try에 두고 catch 안의 logFailure를 보호하지 않는다.
  • Runtime evidence: successful send 뒤 debug logger failure가 warn failure observation을 만들었고, provider failure 뒤 warn logger failure는 caller까지 전파됐다.
  • Comparison: messaging은 같은 shared logger를 observeQuietly로 이미 격리하고 regression test를 갖는다.
  • Why it matters: diagnostics가 business/provider outcome을 바꿔서는 안 된다는 non-authoritative observation 원칙이 consumer마다 달라진다.
  • Verification: 022a focused probe; notification에 throwing-logger regression 추가.
  • Candidate: notification에서 observation isolation 또는 shared logger no-throw contract.
  • Tech-Log: CASE + DECISION 후보.
P3 — support README가 current architecture registry/history와 drift
  • Observed fact: SSOT 위치, CLAUDE.md 존재 여부, HTTP logger 존재 여부가 current source와 불일치.
  • Why it matters: support module의 dependency policy와 비교 설계를 읽는 사람이 현재 architecture를 잘못 이해한다.
  • Verification: 019 raw search/history.
  • Candidate: README를 modules.json/current consumer topology에 맞춰 갱신.
  • Tech-Log: 보통 refactor/doc maintenance; 독립 CASE 우선순위는 낮음.
P3 — cache-redis/httpclient의 support project dependency 필요성 재검증
  • Observed fact: 두 leaf 모두 Gradle support dependency는 있지만 current production Java support reference는 0이고 support resource도 없다.
  • Why it matters: 불필요 edge는 classpath와 architecture narrative를 실제 필요보다 넓힌다.
  • Verification: dependency 제거 후 각 leaf focused test + app composition test.
  • Status: candidate only; downstream leaf exhaustive analysis 전에는 confirmed dead edge로 단정하지 않음.
  • Tech-Log: OPEN QUESTION / refactor candidate.

13. 확인한 것 / 확인하지 못한 것

확인한 것
  • leaf-owned source/test/build/README/CLAUDE 8개 전부 FULL_READ
  • current compile dependency graph
  • registry membership와 allowed project edges
  • app-bootstrap component-scan wiring
  • support public surface reachability
  • optional sibling activation shape
  • duplicate logger/correlation mechanism search
  • README named-claim drift와 relevant Git history
  • messaging/notification direct consumers
  • PII exception-message runtime probe
  • diagnostics-failure runtime probe
  • focused support/messaging/notification/gating tests
  • architecture/dependency verification
이 scope에서 exhaustive하지 않은 것
  • messaging 전체 module
  • notification 전체 adapter module
  • cache-redis 전체 module
  • httpclient 전체 module
  • production Logback deployment/backend 장애 행동
  • 실제 외부 provider SDK의 구체 exception message corpus

따라서 §4의 핵심은 “특정 SDK가 지금 반드시 PII를 exception에 넣는다”가 아니다. 공통 logger contract가 arbitrary exception message를 허용하고 실제로 그대로 출력하므로 PII-safe를 보장할 수 없다는 것이다.

§5 역시 실제 production disk-full 사고를 주장하지 않는다. 현재 compiled consumer에 throwing logger를 주었을 때 outcome semantics가 깨지는 코드 경로를 재현한 것이다.

14. 완료 판정

adapter-outbound-support는 다음 gate를 충족했다.

  • bounded denominator: 8 / unclassified 0
  • production/test source 전부 FULL_READ
  • build dependency와 runtime membership 확인
  • composition-root wiring 확인
  • public contract와 logging semantics 추출
  • success/failure consumer path 추적
  • dedicated tests와 downstream focused tests 실행
  • ArchUnit/project dependency enforcement 확인
  • public reachability probe 수행
  • conditional sibling probe 수행
  • duplicate mechanism probe 수행
  • documentation drift probe 수행
  • raw evidence에 exact command/cwd/time/revision/exit/output 보존
  • P1/P3 backlog와 limitations 분리

따라서 이 bounded scope를 COMPLETE로 판정할 수 있다. 프로젝트 전체는 아직 후속 outbound/persistence/messaging/inbound/bootstrap scopes와 마지막 §A99가 남아 있으므로 계속 IN_PROGRESS다.

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 12개 (main 4 · test 1 · 기타 7).

src/adapter/outbound/support/build.gradle
src/config/architecture/modules.json                    (adapter-outbound-support 항목)

main:
  src/main/java/dev/caskeleton/adapter/outbound/package-info.java
  src/main/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLogger.java
  src/main/java/dev/caskeleton/adapter/outbound/support/OutboundCorrelation.java
  src/main/java/dev/caskeleton/adapter/outbound/support/OutboundSupportConfig.java

test:
  src/test/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLoggerTest.java

기타:
  AGENTS.md
  CLAUDE.md
  README.md
  docs/registries/mdc-keys.yaml
  src/app-bootstrap/build.gradle
  src/build.gradle
  src/config/architecture/modules.json

해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등:
  modules.json
  evidence/raw/015-adapter-outbound-support-inventory.txt
  evidence/raw/021a-support-logger-pii-probe.java
  evidence/raw/021-support-logger-pii-probe.txt
  evidence/raw/022a-notification-logger-failure-probe.java
  evidence/raw/022-notification-logger-failure-probe.txt
  016-adapter-outbound-support-public-reachability.txt
  017-adapter-outbound-support-conditional-wiring.txt
  018-adapter-outbound-support-duplicate-mechanisms.txt
  019-adapter-outbound-support-document-drift.txt
  020-adapter-outbound-support-project-edge-usage.txt
  §A99


A05. adapter-outbound-persistence-jpa

분석 중에는 05-adapter-outbound-persistence-jpa.md 파일이었다. 4,950줄.

adapter-outbound-persistence-jpa 상세 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-outbound-persistence-jpa
  • canonical state analysisFile: §A05 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/outbound/persistence-jpa · Gradle :adapter:outbound:persistence-jpa
  • registry allowed_dependencies: ["domain-core", "application-core", "shared-contract"]
  • registry runtime_memberships: ["app-bootstrap", "sample-portfolio"]
  • coverage ledger: FULL_READ 605 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


상태: COMPLETE
기준 revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
분석 범위: src/adapter/outbound/persistence-jpa
Gradle path: :adapter:outbound:persistence-jpa

0. 왜 내부 sub-scope로 나누는가

이 leaf는 하나의 Gradle module이지만 실제로는 JPA platform의 여러 capability를 package boundary로 합쳐 놓은 큰 구현체다. tracked file denominator는 605개이며 production Java만 350개다. 따라서 module 전체를 한 번에 훑지 않고, 파일이 정확히 하나의 내부 bounded sub-scope에 귀속되도록 ledger를 먼저 고정하고 각 sub-scope를 exhaustive-reading unit으로 처리한다.

전체 denominator
  • tracked module files: 605
  • src/main: 381 files / 350 Java / 31 resources / 약 27,744 Java LOC
  • src/test: 101 files / 100 Java / 1 .gitkeep / 약 9,511 Java LOC
  • src/testkit: 41 Java / 약 3,070 LOC
  • src/postgresqlIntegrationTest: 75 files / 71 Java / 4 SQL / 약 13,958 Java LOC
  • src/jpaPlatformPerformanceTest: 3 Java / 약 268 LOC
  • leaf top-level: CLAUDE.md, README.md, build.gradle, gradle.lockfile
내부 bounded sub-scope ledger
# sub-scope denominator status
1 governance / build / docs / root boundary 11 COMPLETE
2 API contracts (api/**) 55 COMPLETE
3 transaction + persistence failure 51 COMPLETE
4 Spring Data + Hibernate + Querydsl 53 COMPLETE
5 PostgreSQL vendor implementation + vendor migrations 73 COMPLETE
6 baseline capability stores/config/audit/cache/H2/etc. 87 COMPLETE
7 Fileserver persistence + migrations 29 COMPLETE
8 Notification persistence + migrations 68 COMPLETE
9 Experimental platform 38 COMPLETE
10 testkit + testkit fixture tests 62 COMPLETE
11 PostgreSQL integration/readiness lane 75 COMPLETE
12 pool/performance contract lane 3 COMPLETE
TOTAL 605

이 ledger는 module completion 전까지 모든 tracked file의 최종 disposition(FULL_READ / STRUCTURAL_ONLY / EXCLUDED)을 추적하기 위한 내부 작업 단위다. module-level state.json은 이 12개가 모두 닫힐 때만 COMPLETE로 전환한다.

12개 sub-scope는 모두 닫혔다. 최종 disposition은 605 FULL_READ / 0 STRUCTURAL_ONLY / 0 EXCLUDED / 0 UNCLASSIFIED이며, ledger 재조정과 module 완료 조건은 §142에 있다.

1. 모듈 구조의 1차 관찰

CLAUDE.mddocs/jpa/repository-adaptation.md에 따르면 원래 별도의 Stable library module들로 설계된 JPA platform을 이 저장소의 fail-closed leaf registry에 맞춰 하나의 Gradle leaf 내부 package boundary로 적응시켰다. 그래서 이 module의 package graph 자체가 사실상 내부 모듈 그래프 역할을 한다.

실제 build는 production project dependency로 application-core, shared-contract를 선언하고 Spring Data JPA, Spring Integration JDBC, Flyway, Micrometer 등을 사용한다. PostgreSQL/H2 driver는 runtimeOnly, Querydsl/Envers는 compileOnly다. 별도 testkit, postgresqlIntegrationTest, jpaPlatformPerformanceTest source set과 fail-closed lane을 갖는다.

이 문서는 각 sub-scope를 닫아가며 위 설계 문서의 주장과 실제 source/build/test/runtime evidence를 계속 대조한다.


2. Sub-scope 02 — API contracts (api/**)

내부 상태: COMPLETE — 49 production + 6 test, 55 / 55 FULL_READ
범위: src/main/java/dev/caskeleton/adapter/outbound/persistence/api/** + matching dedicated tests
역할: provider/framework implementation보다 안쪽에서 persistence platform의 외부 계약, 실패 의미, query safety, transaction/retry algebra를 고정한다.

2.1 숫자 지도와 package map
package production dedicated test 역할
api root 1 1 bounded persistence operation identity
api.capability 3 0 capability/support-level report vocabulary
api.error 23 2 provider-neutral persistence failure algebra
api.query 10 2 keyset/cursor/query-observation contract
api.transaction 12 1 transaction profile/retry/completion-evidence algebra
합계 49 6 55

public top-level production type도 정확히 49개다. docs/architecture/jpa-api-surface.txt의 committed API baseline 역시 api namespace에서 49개를 기록하고 있어 현재 이름 목록 drift는 없다. Gradle verifyJpaApiSurface가 이 surface의 추가/삭제를 fail-closed로 검증한다.

이 package에는 Spring/JPA/Repository/Entity/Configuration annotation이 하나도 없다. 즉 JPA adapter 안에 위치하지만 API vocabulary 자체는 Spring bean discovery나 JPA mapping으로 활성화되지 않는다. 실제 composition은 app-bootstrap 및 implementation package가 소유한다.

2.2 이 API가 “adapter 내부 DTO”와 다른 이유

api/**는 implementation package와 달리 의도적으로 외부 adopter surface다. committed API baseline 상단도 api를 intended external package로 명시한다. 따라서 다음 두 사실을 구분해야 한다.

  1. repository 내부 production consumer가 있는가
  2. public library contract로 존재할 이유가 있는가

예를 들어 JpaEntityNotFoundException은 현재 repository production에서 자신을 제외한 참조 파일이 0개다. 하지만 이 한 사실만으로 dead type이라고 판정하지 않았다. external API surface는 repository 내부에서 직접 생성되지 않더라도 adopter가 catch/translate하는 계약일 수 있기 때문이다.

반대로 public API라는 이유로 내부 invariant 결함까지 “미사용이라 안전”으로 넘기지는 않는다. SignedJsonCursorCodec처럼 codec 자체가 public contract이고 자기 encode/decode algebra가 불일치하면 repository 내부 consumer 유무와 무관하게 API defect다.

2.3 PersistenceOperationName: 자유 문자열 대신 등록 가능한 identity를 타입으로 만든다

PersistenceOperationName[a-z][a-z0-9.-]{2,95} 형식만 허용한다.

목적은 단순 validation이 아니다. 이 값은 다음 구현 계층에서 실제로 사용된다.

  • retry observation
  • transaction observation
  • migration gate
  • PostgreSQL failure translation
  • JSON/lock observation
  • transaction executor/coordinator

즉 persistence operation의 이름이 metric/trace/policy lookup으로 퍼지기 전에 cardinality와 데이터 유출 가능성을 가장 안쪽 public type에서 제한한다.

전용 테스트는 다음을 확인한다.

  • 동적 identifier를 붙인 이름 거부
  • raw/invalid 형태 거부
  • uppercase/길이 초과 거부
  • registered dotted name 허용

이 구조는 이후 query 쪽 QueryName과 동일한 방향을 가진다. “관측 이름을 호출자가 자유 문자열로 만드는 것”을 허용하지 않는 것이 공통 원칙이다.

3. Capability API — 실행 기능과 지원 등급을 reportable contract로 분리

3.1 JpaCapability

현재 enum은 16개 capability id를 갖는다. app-bootstrap의 JpaPlatformAutoConfiguration.capabilities() 역시 16개를 선언하므로 enum catalog와 current composition count는 일치한다.

Stable composition은 대표적으로 다음을 기본 지원으로 보고한다.

  • transaction retry
  • completion evidence
  • keyset pagination
  • batch
  • schema gate
  • runtime-role verification
  • observability

Advanced capability는 PostgreSQL native write/work claim/JSONB/array-range, bulk DML, stateless session, COPY, L2 cache, Envers 등을 constraints와 함께 보고한다.

이 분리는 “classpath에 코드가 있다”와 “현재 composition이 기본 지원한다고 약속한다”를 동일시하지 않는다. capability enum은 vocabulary이고, CapabilitySupport가 support level을 결합하며, app-bootstrap composition이 실제 현재 report를 구성한다.

3.2 CapabilitySupport

record는:

capability
level
constraints[]

을 가진다.

constructor가 보장하는 것은:

  • capability non-null
  • level non-null
  • constraints list defensive copy
  • 각 constraint non-null / non-blank

이다.

usableByDefault()는 STABLE만 true다. Advanced/Experimental이 “존재하므로 기본 사용 가능”으로 오해되지 않게 support level을 코드에 남긴다.

3.3 actuator까지 이어지는 실제 consumer

CapabilitySupport는 단순 문서용 record가 아니다.

실제 production 흐름은:

JpaPlatformAutoConfiguration.capabilities()
    -> List<CapabilitySupport>
    -> JpaPlatformReport.capabilities
    -> JpaPlatformEndpoint @ReadOperation
    -> management endpoint "jpaplatform"

이다.

JpaPlatformReport는 JDBC URL/user/password/SQL/entity catalog를 필드로 갖지 않도록 설계되어 있고, privilege detail도 boolean으로 축약한다. 즉 management endpoint의 reconnaissance surface를 줄이려는 의도가 source에 명시돼 있다.

3.4 API invariant gap — “bounded constraint”는 타입이 강제하지 않는다

CapabilitySupport javadoc은 constraints를 actuator report에 게시할 수 있는 plain, bounded string으로 설명한다. 그러나 constructor는 길이/형식 상한을 두지 않는다.

focused constructor probe에서 100,000-character constraint가 그대로 accepted/copy되는 것을 확인했다.

constraintLength=100000

다만 current app-bootstrap composition이 만드는 constraints는 모두 source에 고정된 짧은 literal이다. 따라서 현재 shipped composition에서 즉시 100KB user-controlled value가 endpoint에 노출된다고 주장하지 않는다.

판정:

  • Observed: public API type의 “bounded” invariant는 constructor에서 강제되지 않는다.
  • Observed: type은 actuator report의 실제 element type이다.
  • Observed: current composition은 bounded static literal만 생성한다.
  • Conclusion: current exploit/incident가 아니라 P2 API-contract hardening gap이다.

향후 이 type을 외부 composition/fork가 직접 사용하거나 dynamic constraint source가 생기면 report bound가 호출자의 규율에 의존한다. public API가 “safe to publish”를 자기 계약으로 주장하려면 max length/accepted vocabulary를 타입에서 고정하거나, report projection 단계에서 별도 sanitization/bounding이 필요하다.

4. Error API — provider exception을 stable failure algebra로 변환

4.1 FailureCategory가 retry보다 먼저 존재한다

error hierarchy의 핵심은 “예외 class를 많이 만든 것”이 아니라 provider-specific signal을 bounded semantic category로 변환하는 것이다.

대표 category는:

  • serialization failure
  • deadlock
  • optimistic conflict
  • lock not available
  • connection unavailable
  • timeout 계열
  • unique/FK/not-null/check constraint
  • entity not found
  • schema mismatch
  • data corruption
  • completion unknown

등이다.

이 category는 뒤의 retry policy/metric이 SQLSTATE/provider message를 직접 해석하지 않게 하는 중간 vocabulary다.

4.2 JpaFailureContext: telemetry-safe failure metadata

JpaFailureContext가 가지는 정보는 operation, SQLSTATE/constraint, attempt, retryability, completion-unknown, elapsed, trace 등으로 제한된다.

중요한 invariant는 다음이다.

  • arbitrary identifier는 그대로 담지 않고 bounded/redacted form으로 축약
  • malformed SQLSTATE는 redacted
  • absent SQLSTATE는 sentinel로 표현
  • completion unknown과 retryable=true를 동시에 표현할 수 없음
  • completion-unknown factory는 항상 automatic retry를 차단하는 형태를 만든다

즉 “exception이 발생한 뒤 로그에서 실수하지 말자”보다 앞선 위치에서 failure context가 위험한 shape 자체를 표현하기 어렵게 만든다.

4.3 JpaPersistenceException: bounded message와 raw cause의 역할을 분리

base exception message는 provider cause message를 그대로 복사하지 않고 category + bounded context로 만든다. dedicated test도 provider cause에 email marker를 넣었을 때 top-level exception message에 노출되지 않는 것을 검증한다.

동시에 raw Throwable cause는 보존한다. 이는 중요한 구분이다.

exception.getMessage()       -> bounded platform message
exception.getCause()         -> original provider failure

따라서 API 자체의 message contract는 안전하게 설계되어 있지만 downstream logger가 stacktrace/cause message까지 출력해도 안전하다는 뜻은 아니다. 이 여부는 observation/transaction failure logging consumer를 읽을 때 별도 검증해야 한다.

현재 API sub-scope에서는 이를 defect로 확대하지 않고 후속 observation/transaction trace 항목으로 넘긴다.

4.4 constraint exception은 raw constraint name을 외부 meaning으로 쓰지 않는다

ConstraintCode/ConstraintViolationDetails는 DB constraint의 raw name이 application-visible meaning이 되지 않도록 stable code/details로 변환하기 위한 계약이다. 실제 PostgreSQL catalog/translator가 consumer다.

Unique/FK/NotNull/Check exception은 이 details를 결합한다. 즉 application이 uk_user_email_2026_v2 같은 physical identifier를 분기 조건으로 쓰는 대신 stable platform vocabulary에 의존하게 한다.

구체적인 catalog mapping 및 SQLSTATE correctness는 PostgreSQL vendor sub-scope에서 exhaustive하게 검증한다.

4.5 completion unknown을 exception type으로 분리

TransactionCompletionUnknownException은 단순 ConnectionUnavailableException의 한 종류로 흡수되지 않는다.

이 타입은:

  • failure category = completion unknown
  • retryable=false
  • transaction completion evidence
  • bounded optional transaction key

를 결합한다.

constructor도 들어온 context가 retryable 형태라 해도 completion-unknown-safe context로 변환한다. 결과적으로 뒤의 retry policy가 실수하더라도 “commit됐을 수 있는 work를 다시 실행”하는 경로를 만들기 어렵다.

이 contract는 transaction implementation의 evidence frame/commit classifier가 실제로 언제 UNKNOWN을 선택하는지 확인해야 완성된다. 그 실행 의미는 다음 sub-scope의 핵심 대상이다.

4.6 JpaEntityNotFoundException: current repository consumer 0

exact-ish repository production reachability probe에서 이 public type만 현재 source 외 production reference가 0이었다.

이 type은 initial JPA platform commit부터 존재하고 committed API baseline에도 명시적으로 포함된다. 따라서 현 단계 판정은:

confirmed dead code ✗
current repository production consumer 없음 ✓
committed intended external API ✓

이다.

향후 public API budget을 줄이는 refactor를 할 때는 “실제 external adopter가 존재하는지”를 확인할 후보지만, source tree만으로 제거 가능하다고 결론 내리지 않는다.

5. Query API — pagination 비용과 trust boundary를 type shape로 제한

5.1 KeysetPageRequest: offset 자체가 없다

record는:

after: Optional<C>
size: 1..500
direction

만 가진다.

offset/page number를 아예 표현하지 않으므로 keyset API를 사용하는 consumer가 실수로 large offset pagination으로 회귀하기 어렵다.

fetchSize()는 요청 size + 1을 반환한다. 즉 별도 count query 없이 한 row를 더 읽어 hasNext를 판단하는 계약이다.

5.2 KeysetSlice: total count를 contract에서 제거

slice는:

items
nextCursor
hasNext

만 가진다.

invariant:

  • hasNext=true -> nextCursor 필수
  • terminal slice -> nextCursor 금지
  • items defensive copy

이다.

page number/total count가 없다는 것은 API omission이 아니라 의도된 성능 정책이다. “keyset을 쓰면서 매번 count(*)도 수행”하는 모순을 contract shape에서 제거한다.

5.3 QueryNameQueryObservation

QueryName도 bounded registry key다. raw SQL을 metric/trace identity로 사용할 수 없다.

QueryObservation.start(QueryName)QueryScope 구조에서 scope는:

  • rows(count)
  • failure(Throwable)
  • close()

를 제공한다.

특히 QueryScope.failure 문서가 “throwable message를 log하지 말 것”을 직접 계약한다. Micrometer implementation이 이를 실제로 지키는지는 observation sub-scope에서 확인한다.

NoopQueryObservation은 backend가 없을 때도 caller control flow가 갈라지지 않게 singleton no-op scope를 제공한다. app-bootstrap JpaObservabilityAutoConfiguration에서 actual fallback consumer가 존재한다.

6. SignedJsonCursorCodec: 좋은 trust-boundary 설계와 경계값 결함이 동시에 존재

6.1 의도된 security properties

codec은 다음 token을 만든다.

v1.<base64url(payload)>.<base64url(HMAC-SHA256(version + '.' + payload))>

확인한 방어는 다음과 같다.

  • signing key 최소 32 bytes
  • URL-safe Base64 / no padding
  • version까지 MAC input에 포함
  • token 전체 길이 4096-character cap
  • payload 2048-byte cap
  • presented MAC 32-byte exact length 확인
  • MessageDigest.isEqual constant-time comparison
  • MAC 검증 전에 application payload decoder를 호출하지 않음
  • oversized public input을 substring/decode/MAC allocation 전에 거부하려는 선행 check

기존 dedicated tests도 tampering, foreign key, unknown version, short key, oversized token, wrong-length MAC, oversized payload 등을 폭넓게 검증한다.

6.2 Confirmed P2 — encode가 발급한 2046~2048-byte cursor를 decode가 거부한다

문제는 decoded payload size를 decode 전에 추정하는 helper다.

private static int decodedLengthOf(int encodedLength) {
    return encodedLength / 4 * 3 + 3;
}

이 함수는 “최대 decoded size”를 빠르게 계산하려는 의도로 commit 2f5d2fc에서 hostile-input bounds와 함께 추가됐다. 그러나 codec은 unpadded Base64URL을 사용한다.

실제 self-round-trip probe:

size=2045 -> encode OK / decode OK
size=2046 -> encode OK / decode rejects as oversized
size=2047 -> encode OK / decode rejects as oversized
size=2048 -> encode OK / decode rejects as oversized
size=2049 -> encode itself rejects

즉 현재 accepted encode domain과 accepted decode domain이 다르다.

형식적으로:

encode accepts payload bytes <= 2048

decode precheck effectively accepts only a smaller subset

there exists x:
    encode(x) succeeds
    decode(encode(x)) fails

이건 hostile token을 더 엄격히 거부하는 정도가 아니다. codec의 자기 round-trip contract를 깨는 boundary defect다.

실행 evidence:

  • evidence/raw/035a-jpa-cursor-boundary-probe.java
  • evidence/raw/035-jpa-cursor-boundary-probe.txt
6.3 왜 기존 테스트가 못 잡았는가

현재 SignedJsonCursorCodecTest는:

  • ordinary round-trip
  • 2049-byte encode rejection
  • decode 쪽 arbitrary oversized payload segment rejection

을 각각 검증한다.

하지만 MAX_PAYLOAD_BYTES 바로 아래와 정확히 같은 크기에 대해:

decode(encode(payload)) == payload

을 검증하지 않는다.

따라서 security-bound 테스트는 많지만 양쪽 bound가 같은 집합을 표현하는지에 대한 property가 빠져 있다.

수정 후보는 두 방향이다.

  1. unpadded Base64URL의 decoded length를 remainder까지 반영해 정확히 계산
  2. MAC 검증 전에 encoded segment의 최대 허용 길이를 exact bound로 계산하고, 실제 decode 후 byte length도 재확인

어느 구현을 택하든 regression criterion은 최소:

payload sizes: 2045, 2046, 2047, 2048 -> round-trip success
2049 -> encode reject
forged oversized segment -> pre-decode reject

이어야 한다.

Tech-Log 후보: CASE — “DoS 방어용 Base64 사전 크기 검사가 codec의 자기 round-trip을 깨뜨린 경계값 문제”.

7. Transaction API — 실행체보다 먼저 retry 가능 상태를 제한한다

7.1 TransactionProfile

profile은:

  • name
  • propagation
  • isolation
  • timeout
  • readOnly
  • retryProfile

을 결합한다.

write profile은 positive timeout이 필수다. read-only는 zero timeout을 “connection default” 의미로 허용한다.

지원 propagation을 REQUIRED / MANDATORY / REQUIRES_NEW로 좁혀 SUPPORTS/NESTED/NOT_SUPPORTED/NEVER처럼 “실제로 transaction 안에 있는가”를 흐리는 mode를 surface에서 제거했다.

isolation 역시 PostgreSQL에서 의미가 겹치는 READ_UNCOMMITTED를 expose하지 않는다.

7.2 RetryProfile: completion unknown을 config로 다시 살릴 수 없다

retryable category allowlist는 다음 contender 계열로 제한된다.

  • serialization failure
  • deadlock
  • optimistic conflict
  • lock not available
  • connection unavailable

COMPLETION_UNKNOWN을 넣으면 constructor가 즉시 거부한다. Unique constraint 같은 ineligible category도 거부한다.

즉 failure translator가 retryability를 판단하고, profile이 category allowlist를 가진다고 해서 “어떤 failure도 설정으로 retry 가능하게” 만들 수 없다.

7.3 RetryDecision: retry / reconcile / fail을 별도 algebra로 둔다

decision은:

  • RETRY_FULL_TRANSACTION
  • RECONCILE
  • FAIL

세 가지이며 retry만 non-zero delay를 가질 수 있다.

이 분리 덕분에 completion unknown이 delay=0 retry처럼 표현되지 않는다. “모르겠음”을 “즉시 한 번 더”와 구분한다.

7.4 reason의 bounded 주석과 현재 사용

RetryDecision.reason은 javadoc상 bounded diagnostic/low-cardinality-safe string으로 설명된다. 그러나 constructor는 non-null/nonblank만 확인하고 길이/형식 상한은 없다.

runtime constructor probe에서는 100,000-character reason도 accepted됐다.

retryDecisionReasonLength=100000

다만 actual JpaRetryObservation은 decision.reason을 metric tag로 사용하지 않는다. metric tag는:

  • persistence unit
  • operation
  • failure category
  • retry disposition

으로 구성된다. current repository의 retry-decision reason accessor도 retry policy test 외 실질 telemetry consumer가 확인되지 않았다.

따라서 현재 판정은:

  • docs/type invariant mismatch: observed
  • current metric cardinality incident: not observed
  • 우선순위: P3 API hardening/document precision candidate

이다.

7.5 maxAttempts에는 타입-level upper bound가 없다

RetryProfile.maxAttempts는 최소 1만 강제한다. probe에서 Integer.MAX_VALUE도 구성 가능했다.

이것만으로 retry storm defect라고 판정하지 않는다. 실제 coordinator에는 elapsed-time budget도 있고 backoff와 profile binding이 별도로 존재한다. 따라서 maxAttempts의 effective runtime bound는 RetryBudget, settings, composition을 포함해 다음 transaction sub-scope에서 판단한다.

7.6 cross-scope candidate — fallback policy branch의 도달 가능성

API consumer trace 과정에서 FullTransactionRetryCoordinator는:

profile.retryProfile() == null
    ? fallbackRetryPolicy
    : DefaultJpaRetryPolicy.forProfile(profile.retryProfile())

로 분기한다.

그런데 TransactionProfile compact constructor는 retryProfileObjects.requireNonNull로 강제한다. public constructors/factory도 모두 non-null profile을 만든다.

따라서 current type algebra만 보면 fallback branch는 도달 불가능해 보인다. 그러나 이 파일은 transaction sub-scope 소유이고 coordinator 생성/wiring/history까지 읽지 않았으므로 이번 API scope에서는 dead-path candidate로만 넘긴다.

8. Negative-space probes — API scope

8.1 Public surface reachability

Raw: 032-persistence-jpa-api-public-reachability.txt

49개 public type을 exact import/FQN + same-package reference 기준으로 production tree와 비교했다.

핵심 결과:

  • 대부분 implementation/app-bootstrap consumer가 존재
  • JpaEntityNotFoundException: current repository production reference 0
  • 일부 codec/SPI는 implementation 내부 또는 app-bootstrap fallback으로만 소비

단 이 probe는 external adopter, reflection/generated code를 볼 수 없고 same-package Javadoc reference를 과대계수할 수 있다. 따라서 zero reference만 meaningful negative evidence로 사용했다.

8.2 Conditional-wiring sibling comparison

API package 안에는 Spring configuration/conditional/entity/repository stereotype가 없다.

반면 actual runtime composition은 app-bootstrap의:

  • JpaPlatformRuntimeAutoConfiguration
  • JpaObservabilityAutoConfiguration
  • JpaTransactionAutoConfiguration

등이 소유한다.

즉 API가 자체 component scan/autoconfiguration으로 몰래 활성화되는 경로는 확인되지 않았다. 이는 “adapter leaf는 implementation을 제공하고 app-bootstrap이 composition을 소유한다”는 repository 정책과 맞는다.

8.3 Duplicate-mechanism sweep

repository에는 다른 bounded context에 같은 simple name이 있다.

  • inbound-web CursorCodec
  • httpclient RetryDecision
  • httpclient FailureCategory
  • cache-redis SortDirection

그러나 package와 input/output responsibility가 서로 다르다.

예를 들어 inbound web cursor codec은 transport pagination cursor 계약이고, persistence cursor codec은 ordering-key payload에 대한 HMAC integrity seam이다. httpclient retry algebra 역시 HTTP request/retry ambiguity를 다룬다.

따라서 same-name duplication은 존재하지만 현재 evidence로 competing implementation defect는 아니다. 오히려 bounded context별 vocabulary가 우연히 같은 이름을 갖는 경우다.

8.4 Documentation / count drift
  • committed API baseline: 49 api top-level types
  • current source: 49
  • JpaCapability enum: 16
  • current app-bootstrap capability declarations: 16

현재 수치 drift 없음.

API surface verification도 별도 Gradle task가 소유하므로 수동 문서 count만 믿는 구조가 아니다.

9. 테스트와 증명 범위

9.1 Dedicated API tests

전용 test class는 6개다.

  • PersistenceOperationNameTest
  • JpaFailureContextTest
  • JpaPersistenceExceptionTest
  • QueryNameTest
  • SignedJsonCursorCodecTest
  • TransactionProfileTest

fresh --rerun-tasks 실행에서 모두 통과했다.

이들은 다음을 잘 검증한다.

  • low-cardinality operation/query name shape
  • completionUnknown/retryable contradiction 차단
  • top-level exception message의 provider-message 비노출
  • cursor signature/tamper/version/size basics
  • transaction/retry profile의 주요 unsafe shape

하지만 확인된 cursor self-round-trip boundary property는 포함하지 않는다.

9.2 API surface verification

verifyJpaApiSurface --rerun-tasks가 통과했다.

이 task가 증명하는 것은 public type names가 committed baseline과 동일하다는 것이다. method semantics나 constructor invariant까지 ABI/API compatibility를 검증하는 것은 아니다.

9.3 app-bootstrap capability composition test

current repository composition은 별도 app-bootstrap test로 확인한다. 이 test는 현재 16개 capability와 default usable support 등을 검증하지만 public CapabilitySupport에 arbitrary external constraints가 들어오는 경우의 bound를 검증하는 test는 아니다.

10. API sub-scope findings backlog

P2 — SignedJsonCursorCodec accepted encode domain과 decode domain 불일치
  • Observed: 2046~2048-byte payload는 encode 성공 후 자기 token decode 실패.
  • Cause shape: unpadded Base64URL encoded segment의 decoded byte count를 encodedLength / 4 * 3 + 3으로 과대 추정.
  • Why it matters: public paging cursor codec이 자기가 발급한 token을 다음 page에서 거부할 수 있음.
  • Existing tests: green이지만 exact-bound round-trip 없음.
  • Verification: 035 runtime probe + 향후 boundary regression/property test.
  • Candidate fix: exact unpadded Base64 decoded-size arithmetic 또는 encoded-length exact cap + post-decode byte cap.
  • Tech-Log: CASE 우선 후보.
P2 — CapabilitySupport.constraints의 bounded/report-safe 계약이 타입에서 강제되지 않음
  • Observed: 100,000-character constraint accepted.
  • Observed: capability list는 JpaPlatformReport를 통해 actuator endpoint model에 포함됨.
  • Observed: current shipped composition은 static short literals만 생성.
  • Impact: 현 composition incident가 아니라 public API invariant gap; fork/dynamic composition에서 bound가 caller discipline에 의존.
  • Candidate fix: type-level max length/vocabulary 또는 report projection에서 bounding.
  • Tech-Log: OPEN QUESTION/DECISION 후보; 실제 외부 dynamic source가 확인되면 CASE 승격 가능.
P3 — RetryDecision.reason의 “bounded” 설명과 constructor contract 불일치
  • Observed: 100,000-character reason accepted.
  • Observed: current retry metrics는 reason을 tag로 사용하지 않음.
  • Impact: 현재 cardinality defect로 확인되지 않음.
  • Candidate: length bound를 추가하거나 javadoc의 low-cardinality claim을 실제 사용 범위에 맞게 좁힘.
Cross-scope candidate — retry fallback branch reachability
  • TransactionProfile.retryProfile은 non-null invariant.
  • FullTransactionRetryCoordinator는 null retryProfile을 fallback policy 선택 조건으로 사용.
  • transaction wiring/history까지 확인 후 dead branch인지 판정.
External-surface candidate — JpaEntityNotFoundException
  • current repository production consumer 0.
  • committed intended external API baseline에는 존재.
  • external adoption evidence 없이 dead/remove 판정 금지.

11. API sub-scope에서 확인한 것과 남긴 경계

FULL_READ
  • production api/**: 49 / 49
  • dedicated test api/**: 6 / 6
  • unclassified: 0
Cross-scope evidence로 읽은 consumer
  • JpaPlatformAutoConfiguration
  • JpaPlatformReport
  • JpaPlatformEndpoint
  • JpaPlatformRuntimeAutoConfiguration relevant wiring
  • JpaRetryObservation
  • DefaultJpaRetryPolicy
  • FullTransactionRetryCoordinator relevant control flow

이 consumer 파일들은 API 의미를 확인하기 위한 cross-scope trace이며, 해당 소유 sub-scope 전체가 FULL_READ됐다는 뜻은 아니다.

다음 sub-scope로 넘긴 것
  • 실제 transaction begin/commit/rollback/evidence semantics
  • RetryBudget가 maxAttempts/defaultMaxElapsed를 어떻게 결합하는지
  • completion unknown record/reconciliation path
  • provider failure translator chain과 raw cause logging
  • Micrometer query scope가 Throwable message를 실제로 무시하는지
  • PostgreSQL SQLSTATE/constraint mapping correctness

12. Sub-scope 03 — transaction + persistence failure

내부 상태: COMPLETE — 32 production + 19 test, 51 / 51 FULL_READ
범위: persistence/transaction/**, persistence/failure/**와 matching dedicated tests
핵심 질문: transaction을 여는 코드가 아니라 commit 결과를 언제 확정하는가, 어떤 failure만 replay하는가, completion-unknown을 어떤 evidence로 남기는가.

12.1 숫자 지도
package production dedicated test 역할
transaction 29 18 application transaction port, JPA executor, retry, deadline, completion evidence
failure 3 1 shared operational-error translation
합계 32 19 51

모든 51개 source/test를 FULL_READ했다. 이 scope에서는 implementation class를 샘플링하지 않고 transaction state machine, retry budget, Spring mapping, failure translation, root wiring, consumer reachability까지 연결했다.

13. 같은 leaf 안에 두 개의 transaction model이 존재한다

현재 persistence-jpa에는 transaction을 표현하는 두 계열이 동시에 존재한다.

A. application-core canonical boundary
PolicyTransactionPort / TransactionPort
    -> SpringTransactionPort (@Component)
        -> SpringPolicyTransactionPort
            -> PlatformTransactionManager

input/output vocabulary:

  • TransactionRequest
  • TransactionPolicyId
  • CallBudget
  • TransactionResult
  • TransactionOutcome
  • OperationId
  • TransactionPhase
  • ReconciliationReference

이 모델은 application-core가 소유한다. use case가 outbound adapter type을 import하지 않아도 transaction policy와 uncertain outcome을 표현할 수 있다.

B. persistence-jpa public API boundary
JpaTransactionExecutor
    -> SpringJpaTransactionExecutor
        -> FullTransactionRetryCoordinator
            -> TransactionProfile / RetryProfile / JpaRetryPolicy

input/output vocabulary:

  • PersistenceOperationName
  • TransactionProfile
  • RetryProfile
  • JpaPersistenceException
  • TransactionCompletionEvidence

JpaPlatformRuntimeAutoConfigurationPlatformTransactionManager가 있으면 SpringJpaTransactionExecutor bean을 만들고, 그 executor가 있으면 FullTransactionRetryCoordinator bean도 만든다.

따라서 source tree 수준에서는 B가 단순 historical class가 아니라 현재 runtime bean graph에도 포함되는 구현이다.

그러나 repository production call search에서는 FullTransactionRetryCoordinator.execute(...)를 실제 business/application code가 호출하는 경로가 확인되지 않았다. 반대로 application-core transaction port는 sample/use-case/composition에서 canonical contract로 사용된다.

이 공존 자체는 곧바로 defect가 아니다. api/**는 intended external surface이므로 fork/application이 B를 programmatically 사용할 수 있다. 문제는 문서가 두 boundary의 관계를 일관되게 설명하지 못하고, 일부 composition helper는 실제 type relationship과 다른 설명을 한다는 점이다.

14. SpringTransactionPort: application-core의 실제 Spring 구현

SpringTransactionPortPolicyTransactionPort를 구현하며 JpaAdapterComponentsConfig의 narrow component scan으로 등록된다.

이 wiring은 중요하다. root CaSkeletonApplication은 persistence package를 broad scan에서 의도적으로 제외한다. 그래서 adapter leaf 내부의 @Component를 “annotation이 있으니 알아서 등록될 것”이라고 볼 수 없다.

JpaAdapterComponentsConfig source에는 과거 실제 회귀가 기록돼 있다.

  • persistence package를 broad scan에서 제외
  • SpringTransactionPort 같은 component를 별도 scan하지 않음
  • 처음 transaction port가 필요한 capability가 조립될 때 unsatisfied dependency로 드러남
  • 해결: JPA master switch 아래에서만 persistence adapter package를 narrow scan

즉 이 module에서 Spring stereotype의 존재와 runtime reachability는 별개다. current root는 PersistenceJpaRootAutoConfiguration -> JpaAdapterComponentsConfig -> component scan 체인을 통해 이를 해결한다.

14.1 기본 transaction mode

TransactionPort primitive는 다음으로 매핑된다.

application operation Spring propagation isolation read-only
inWrite REQUIRED READ_COMMITTED false
inRootWrite REQUIRED READ_COMMITTED false
inRead REQUIRED READ_COMMITTED true
inNew REQUIRES_NEW READ_COMMITTED false

특히 vendor default isolation에 맡기지 않고 READ_COMMITTED를 명시한다.

inRootWrite는 REQUIRED이지만 일반 inWrite와 의미가 다르다. 시작 전에 TransactionSynchronizationManager.isActualTransactionActive()를 확인해 ambient physical transaction이 있으면 manager/action 호출 전에 거부한다. “root boundary”를 REQUIRED의 join semantics로 조용히 바꾸지 않는다.

focused test는 실제로 manager call count/action call count까지 0인지 확인한다.

14.2 caller-visible 성공은 physical commit 이후

SpringTransactionPortTest는 다음을 고정한다.

  • work가 value를 만들었다고 바로 caller에게 반환하지 않음
  • transaction template/manager commit이 끝난 뒤에만 success가 caller-visible
  • commit failure면 work value를 반환하지 않음
  • action failure는 rollback
  • REQUIRES_NEW는 별도 propagation

이것은 application-core의 TransactionPort javadoc이 요구한 “return after physical commit”을 adapter가 실제로 구현하는 evidence다.

15. SpringPolicyTransactionPort: transaction result를 boolean 성공/실패보다 세밀하게 표현

PolicyTransactionPort.inTransaction(...)은 단순 예외 기반 wrapper가 아니다.

결과는 최소 다음 상태를 구분한다.

  • Committed
  • CommittedWithPostCommitFailure
  • Participating
  • DeterminateRollback
  • Indeterminate

핵심은 commit exception = rollback으로 가정하지 않는 것이다.

15.1 commit failure 분기

commit 호출 전에 phase를 COMMIT_REQUESTED로 올리고, Spring TransactionSynchronization sentinel로 실제 callback을 관찰한다.

commit에서 exception이 발생해도:

  1. afterCommit()이 이미 확인됐으면 CommittedWithPostCommitFailure
  2. rollback callback/UnexpectedRollbackException/replay-candidate가 확인되면 DeterminateRollback
  3. 그 외에는 Indeterminate

로 나눈다.

즉 연결 끊김 같은 애매한 exception을 “rollback이겠지”라고 간주하지 않는다.

15.2 canonical application path는 자동 duplicate replay를 막는다

Indeterminate는 retry 대상이 아니다.

replay 조건은 모두 만족해야 한다.

  • policy = COMMAND_SERIALIZABLE_REPLAY_SAFE
  • 현재 attempt가 physical transaction owner
  • attempt < configured max
  • current thread not interrupted
  • result가 DeterminateRollback
  • failure가 40001 serialization 또는 40P01 deadlock replay candidate

따라서 commit ack를 못 받은 상태는 replay되지 않는다.

이 점은 뒤에서 다룰 JPA public API completion-evidence wiring gap의 중요한 mitigation이다. 현재 canonical application path는 completion evidence infrastructure가 없어도 불확정 commit을 자동 재실행하지 않는다.

다만 current SpringPolicyTransactionPort가 만드는 TransactionResult.IndeterminatereconciliationReference는 두 생성 경로 모두 Optional.empty()다. 즉 application-core type은 durable reconciliation reference를 표현할 수 있지만 이 adapter는 현재 그 reference를 채우지 않는다.

16. CallBudget를 transaction timeout보다 먼저 적용한다

application policy path는 timeout을 단순히 TransactionDefinition.setTimeout() 하나로 끝내지 않는다.

16.1 JpaTransactionSettings

ca-skeleton.jpa.transaction settings는 transaction/resource-budget defaults를 가진다.

주요 invariant:

  • duration positive
  • duration <= 1 day
  • retry max attempts 1..5
  • statement timeout <= transaction timeout
  • lock timeout < statement timeout
  • completion/acquisition/action margin hierarchy

즉 runtime에서 무한 retry나 무한 transaction timeout을 property 하나로 열 수 없게 hard cap을 둔다.

이 점은 API RetryProfile.maxAttempts가 upper bound를 갖지 않는 것과 대비된다. canonical application path는 실제 deployment settings에서 최대 5회를 강제한다.

16.2 TransactionDeadlineCalculator

CallBudget admission은 connection pool을 빌리기 부터 시작한다.

transaction을 열 가치가 있으려면 남은 budget이 최소 다음을 감당해야 한다.

pool acquisition reserve
+ transaction begin reserve
+ minimum action budget
+ completion margin

begin 후에는 실제 남은 budget으로:

  • Spring whole-transaction timeout
  • statement timeout
  • lock timeout
  • idle-in-transaction timeout

을 다시 계산한다.

따라서 pool에서 오래 기다린 요청이 “원래 5초 timeout이었으니 DB에서 다시 5초”를 받지 않는다. 이미 소비한 wall-clock budget을 transaction layer가 다시 주지 않는 구조다.

16.3 TransactionRetryBackoff

canonical path의 retry backoff도 CallBudget-aware다.

다음 attempt를 시작하기 전에:

  • jitter delay
  • 다음 acquisition reserve
  • 다음 최소 transaction/action margin

을 모두 감당할 수 있는지 확인한다.

budget이 부족하면 sleep 후 시작했다가 즉시 timeout되는 대신 retry 자체를 포기한다.

17. retry classification은 structured state로 제한한다

TransactionRetryClassifier는 cause chain에서 SQLSTATE를 찾지만 automatic replay candidate는:

  • 40001
  • 40P01

뿐이다.

08007 같은 transaction-resolution-unknown은 candidate가 아니다.

SpringPolicyTransactionPort는 ordinary command에서 40001이 나더라도 COMMAND_SERIALIZABLE_REPLAY_SAFE가 아니면 retry하지 않는다. failure 종류뿐 아니라 업무 side-effect가 replay-safe하다고 application policy가 선언했는가가 함께 필요하다.

이것은 “DB가 retryable이라고 말하니 use case를 다시 실행”하는 구조와 다르다.

18. public JPA path: SpringJpaTransactionExecutor

이 executor는 한 번의 physical attempt만 담당한다. 자체 retry는 하지 않는다.

실행 순서:

TransactionEvidenceContext.begin(...)
    -> TransactionTemplate.execute(work)
    -> success return
or
    -> attempt boundary에서 failure translation
    -> translated runtime exception rethrow
finally
    -> TransactionEvidenceScope close

attempt boundary에서 operation, attempt number, elapsed time, reconciliation key를 알고 있으므로 raw provider exception을 JpaPersistenceException으로 변환하는 위치로 사용된다.

vendor translator가 조립되면 PostgreSQL 40001/40P01 같은 structured SQLSTATE가 stable exception으로 바뀌어 coordinator가 처리할 수 있다.

19. FullTransactionRetryCoordinator: whole-use-case retry 의도

coordinator는 JpaPersistenceException만 catch하고, retry decision에 따라 새 transaction / 새 persistence context에서 전체 work를 다시 호출한다.

설계상 중요한 guard:

  • completion unknown -> no retry
  • irreversible side effect context -> no retry
  • retry budget elapsed -> stop
  • max attempts -> stop
  • backoff interrupt -> stop
  • retry listener는 observation only

이 모델 자체의 unit tests는 강하다. serialization/deadlock retry, exhaustion, completion unknown no-retry, interrupted sleep, irreversible side effect 등을 검증한다.

하지만 current implementation에는 public composition contract와 맞지 않는 별도 defect가 있다.

20. Confirmed P2 — application-supplied JpaRetryPolicy가 valid execution에서 무시된다

JpaTransactionAutoConfiguration은 명시적으로 다음 overload를 제공한다.

retryCoordinator(
    SpringJpaTransactionExecutor executor,
    JpaRetryPolicy policy,
    RetryEventListener listener)

javadoc도 **“retry coordinator for an application-supplied policy”**라고 설명한다.

constructor는 이 policy를 fallbackRetryPolicy로 저장한다.

그러나 FullTransactionRetryCoordinator.execute(...)는 실행마다:

profile.retryProfile() == null
    ? fallbackRetryPolicy
    : DefaultJpaRetryPolicy.forProfile(profile.retryProfile())

를 선택한다.

API TransactionProfile compact constructor는 retryProfile을 non-null로 강제한다. 따라서 valid TransactionProfile을 사용하면 fallbackRetryPolicy branch는 도달할 수 없다.

더구나 바로 다음 RetryBudget.forProfile(profile.retryProfile(), ...)도 non-null profile을 요구하므로 null branch가 hypothetically 열려도 정상 execution model과 맞지 않는다.

실행 probe

custom policy를 다음처럼 넣었다.

custom policy decision = always FAIL
custom policy invocation counter
valid TransactionProfile with maxAttempts=2
work = SerializationFailureException

결과:

customPolicyCalls=0
workCalls=2

즉 custom policy가 “retry하지 말라”고 해도 한 번도 호출되지 않고 default profile policy에 따라 work가 두 번 실행됐다.

Raw:

  • evidence/raw/047a-jpa-custom-retry-policy-probe.java
  • evidence/raw/047-jpa-custom-retry-policy-probe.txt

이것은 단순 dead field가 아니라 public composition factory가 제공하는 custom policy 기능이 실제로 작동하지 않는 functional contract bug다.

우선순위: P2

수정 방향 후보:

  1. coordinator가 constructor-supplied policy를 authoritative하게 사용하고 budget만 profile에서 계산
  2. custom policy overload를 제거하고 RetryProfile이 단일 SSOT임을 API에 명시
  3. custom policy가 profile-aware해야 한다면 factory에서 policy/profile을 하나의 object로 합성

현재처럼 두 설정원을 받되 하나를 silent ignore하는 형태가 가장 위험하다.

21. completion evidence state machine 자체는 잘 설계돼 있다

EvidenceAwareJpaTransactionManagerJpaTransactionManager를 상속하고 transaction phase를 TransactionEvidenceContext에 기록한다.

대략:

NOT_STARTED
 -> ACTIVE       (begin)
 -> COMMITTING   (provider commit 직전)
 -> COMMITTED    (provider commit return)

rollback은 ROLLED_BACK으로 표시한다.

commit 중 RuntimeException이 발생하면 CommitFailureClassifiercommit phase라는 사실과 cause chain을 함께 보고 completion unknown 여부를 판단한다.

21.1 CommitFailureClassifier

completion unknown candidate:

  • SQLSTATE 40003
  • connection class 08*
  • admin shutdown / crash / cannot-connect-now 계열
  • transport break cause

이다.

중요한 건 이 classifier를 generic SQLSTATE translator 대신 commit call 내부에서만 적용한다는 것이다.

connection reset이 query 실행 중 발생했다면 connection unavailable일 수 있지만, provider에게 COMMIT을 보낸 후 reset됐다면 “commit됐는지 모름”이다. SQLSTATE만으로 이 둘을 구분할 수 없고 transaction phase가 필요하다.

PostgreSQL classifier source도 이 이유를 직접 설명한다.

22. historical regression — REQUIRES_NEW evidence stack ownership

TransactionEvidenceContext는 single ThreadLocal slot이 아니라 stack을 사용한다.

이유는 REQUIRES_NEW 때문이다.

과거에는:

  • transaction manager가 commit/rollback 후 frame pop
  • executor finally도 자신이 push한 frame을 pop

두 owner가 존재했다.

outer와 inner가 같은 operation/attempt를 가진 경우:

  1. inner manager가 inner frame pop
  2. inner executor finally가 top을 보고 outer frame까지 자기 것이라고 착각해 pop
  3. outer commit failure 시 operation/reconciliation key evidence가 사라짐

현재는:

  • manager는 phase만 mark
  • TransactionEvidenceScope만 pop owner
  • scope는 depth identity를 갖고 자기 frame이 top일 때만 pop

으로 고쳐졌다.

TransactionEvidenceScopeTestEvidenceAwareJpaTransactionManagerTest가 nested/thread-local cleanup을 고정한다.

이것은 현재 defect가 아니라 잘 복구된 historical CASE 후보다. Clean Architecture보다 transaction infrastructure의 “소유권을 하나로 만들지 않으면 lifecycle evidence가 깨진다”는 주제로 가치가 있다.

23. Confirmed P1 — Stable completion-evidence capability가 shipped composition에 설치되지 않는다

여기서는 알고리즘 존재와 runtime wiring을 분리해야 한다.

23.1 custom manager production construction = 0

production source 전체에서:

EvidenceAwareJpaTransactionManager.standard(...)
new EvidenceAwareJpaTransactionManager(...)

호출이 없다.

JpaTransactionAutoConfiguration 문서는:

manager itself is constructed inside the persistence leaf ... composition root owns the decision whether to install it

라고 설명하지만, 현재 persistence root/import/config 어디에도 실제 installation code가 없다.

commitFailureClassifier() factory도 production consumer가 없다.

따라서 SpringJpaTransactionExecutorTransactionEvidenceContext.begin()으로 frame을 만들더라도 일반 PlatformTransactionManager는 그 frame을 ACTIVE/COMMITTING/COMMITTED로 advance하지 않는다. frame은 기본 NOT_STARTED 상태로 남는다.

23.2 실제 commit-ack-loss classification probe

현재 compiled executor에 normal fake PlatformTransactionManager를 넣고 commit에서:

TransactionSystemException
  cause -> SQLException SQLSTATE 08006

를 발생시켰다.

현재 vendor translator까지 포함한 결과:

type=ConnectionUnavailableException
category=CONNECTION_UNAVAILABLE
completionUnknown=false
retryable=false

Raw:

  • evidence/raw/050a-jpa-commit-ambiguity-probe.java
  • evidence/raw/050-jpa-commit-ambiguity-probe.txt

이 결과는 중요한 두 면을 가진다.

안전하게 남은 부분

CONNECTION_UNAVAILABLE은 current PostgreSQL translator에서 retryable=false다. 따라서 이 probe의 lost commit ack가 coordinator에서 자동 duplicate retry되는 것은 확인되지 않았다.

깨진 부분

하지만 commit call 중 connection을 잃었다는 phase-sensitive 의미가 사라졌다. caller는 “DB에 연결할 수 없었다”와 “COMMIT을 보냈고 결과를 모른다”를 구분할 수 없다.

이 구분을 위해 만들어진 CommitFailureClassifier/TransactionCompletionUnknownException이 composition에서 동작하지 않는다.

23.3 reconciliation record production path = 0

CompletionUnknownRecordCompletionUnknownRecorder는 current production에서 자신들의 정의 외 consumer/implementation이 없다.

그런데 documentation은 훨씬 강한 계약을 선언한다.

support matrix:

Commit completion evidence = Stable
Automatic reconciliation unsupported.
The platform records; the domain resolves.

runbook:

Signal:
- jpa.transaction.completion.unknown incremented
- a CompletionUnknownRecord in the reconciliation channel

그리고 operator procedure는 그 record의 transactionKey를 사용하라고 한다.

현재 이 record를 실제로 쓰는 production channel은 확인되지 않았다.

23.4 completion-unknown metric도 현재 transaction path에서 호출되지 않는다

JpaTransactionObservation.recordCompletionUnknown(...) 구현은 존재한다.

하지만 production에서:

  • JpaObservabilityAutoConfiguration construction = 0
  • JpaTransactionObservation.recordCompletionUnknown(...) call = 0
  • recordCommitted/recordRolledBack/recordTimedOut call도 0

이다.

JpaPlatformRuntimeAutoConfiguration이 만드는 default RetryEventListener도 empty implementation이며, JpaObservabilityAutoConfiguration을 통해 metric listener로 합성하지 않는다.

따라서 runbook의 jpa.transaction.completion.unknown signal은 현재 source wiring으로는 생성 근거를 찾지 못했다.

이 observability factory 전체의 reachability 문제는 later observation/baseline capability sub-scope에서 다시 exhaustive하게 확인한다. 여기서는 completion-unknown path의 cross-scope evidence로만 기록한다.

23.5 canonical application boundary의 mitigation

이 defect가 곧 “현재 모든 use case가 unknown commit을 duplicate retry한다”는 뜻은 아니다.

canonical SpringPolicyTransactionPort는 독립적인 Spring synchronization sentinel을 사용해 commit exception을 TransactionResult.Indeterminate로 반환하며 replay하지 않는다.

즉 current application path의 automatic retry safety는 별도 mechanism으로 유지된다.

하지만:

  • JPA platform public executor/coordinator가 advertised completion evidence를 제공하지 못함
  • durable record 없음
  • runbook metric 없음
  • application Indeterminate도 reconciliationReference는 empty

이므로 advertised operator reconciliation contract는 충족되지 않는다.

우선순위: P1 — reliability / data-integrity operations contract

범위 한정:

  • current canonical PolicyTransactionPort는 no-auto-retry safety를 유지한다.
  • P1은 “commit ambiguity를 stable semantic + durable reconciliation evidence로 표면화한다”는 JPA platform 약속이 실제 composition에서 빠진 점이다.
  • real PostgreSQL lost-ack end-to-end behavior는 later PostgreSQL integration lane에서 추가 qualification해야 한다.

Tech-Log: CASE + DECISION 강한 후보.

24. dual transaction stack의 architecture drift

commit 2f5d2fc에서 old @RetryableJpaTransaction interceptor가 삭제됐다.

그 diff는 이유를 명시한다.

  • application service에 outbound adapter annotation을 붙이면 dependency direction 역전
  • canonical boundary는 PolicyTransactionPort.inTransaction(...)

여기까지는 Clean Architecture와 일치한다.

문제는 이어지는 문장이:

the retry coordinator below is what implements it, not a second way to ask for the same thing

이라고 말한다는 점이다.

실제 type graph는 그렇지 않다.

PolicyTransactionPort
  <- SpringTransactionPort

FullTransactionRetryCoordinator
  X implements PolicyTransactionPort 아님

또 runtime auto-configuration은 coordinator를 별도 bean으로 계속 만든다.

따라서 현재 code/doc 관계는:

  • annotation-based retry path는 제거됨
  • application-core canonical port implementation은 별도로 생김
  • old/public JPA executor+coordinator model도 남음
  • 문서는 coordinator가 canonical port를 구현한다고 잘못 설명

이다.

이것은 단순 문장 오타보다 architecture transition이 완전히 정리되지 않은 흔적이다.

우선순위: P2 architecture consistency

결정이 필요하다.

  1. JPA executor/coordinator를 진짜 external platform API로 유지한다면 canonical application port와 역할 차이를 명시하고 runtime bean/export 정책을 분리
  2. application-core port로 완전히 수렴한다면 coordinator/profile registry 등 old path를 deprecate/remove하고 completion evidence를 canonical path로 이식

현재처럼 “한 경로라고 문서화했지만 실제 두 경로가 존재”하는 상태는 유지보수자가 어느 retry/evidence system을 고쳐야 하는지 혼란을 만든다.

25. P3 — TransactionProfileRegistry는 declarative retry 제거 후 legacy residue 후보

TransactionProfileRegistry는 current production reference가 0이다.

history를 보면 initial design에서는:

@RetryableJpaTransaction
 -> RetryableJpaTransactionInterceptor
 -> TransactionProfileRegistry
 -> FullTransactionRetryCoordinator

형태였다.

commit 2f5d2fc에서 annotation/interceptor와 그 test를 삭제했지만 registry는 남았다.

현재:

  • production consumer 0
  • dedicated unit test만 존재
  • api/** intended external surface가 아니라 implementation transaction package
  • root bean wiring도 없음

이 evidence 범위에서는 confirmed unused production implementation candidate로 볼 수 있다.

단 repository 밖 reflection/external direct construction은 source search로 알 수 없으므로 즉시 삭제 가능성까지 확정하지 않는다. module의 non-api package는 intended external이 아니라는 architecture policy와 함께 보면 cleanup 우선순위는 높아진다.

우선순위: P3 cleanup

26. zero-reference지만 dead가 아닌 JpaTransactionConfig

반대로 JpaTransactionConfig도 direct production reference는 거의 없다.

하지만 이 class는:

@Configuration
@EnableConfigurationProperties(JpaTransactionSettings.class)

이고 JpaAdapterComponentsConfig가 transaction package를 component scan한다.

따라서 direct Java call/import가 0이어도 runtime reachability가 있다.

이 class source 자체도 historical reason을 기록한다.

  • root @ConfigurationPropertiesScan에서 optional persistence tree 제외
  • JPA on 상태에서도 settings가 아무도 bind하지 않던 문제 발생
  • transaction port construction 실패
  • package-local configuration으로 JPA master switch 안에서만 settings enable

이 사례는 mandatory public-reachability probe가 필요한 이유를 잘 보여준다. static reference count만으로 dead code를 찾으면 Spring discovery path를 오탐한다.

27. 두 failure translator 계열은 현재 역할이 다르다

이 scope에는 이름이 비슷한 두 translation mechanism이 있다.

PersistenceFailureTranslatorChain

input:

raw persistence/provider failure

output:

JpaPersistenceException hierarchy

consumer:

SpringJpaTransactionExecutor / retry semantics

목적은 SQLSTATE/optimistic conflict를 retry/completion semantics에 필요한 stable persistence failure로 바꾸는 것이다.

failure.PersistenceExceptionTranslator

input:

RuntimeException / SQLSTATE mapping

output:

shared OperationalError / PersistenceFailureException

consumer는 adapter/application error boundary 쪽이다.

따라서 동일 이름 영역을 다루지만 current evidence로는 competing duplicate implementation이 아니다. transaction retry algebra와 platform operational error mapping이라는 서로 다른 output contract를 가진다.

PostgreSQL vendor translator와 exact SQLSTATE catalog correctness는 vendor sub-scope에서 계속 검증한다.

28. conditional-wiring probe

transaction 관련 configuration은 세 종류로 나뉜다.

28.1 component-scan-owned
  • SpringTransactionPort
  • PersistenceExceptionTranslator
  • JpaTransactionConfig

PersistenceJpaRootAutoConfiguration이 JPA master switch ON일 때 JpaAdapterComponentsConfig를 import하고, 그 narrow scan이 이들을 찾는다.

28.2 runtime bean-factory-owned

JpaPlatformRuntimeAutoConfiguration:

  • SpringJpaTransactionExecutorPlatformTransactionManager가 있을 때
  • FullTransactionRetryCoordinator — executor가 있을 때
  • default empty RetryEventListener
28.3 현재 설치되지 않는 specialized implementation
  • EvidenceAwareJpaTransactionManager
  • CompletionUnknownRecorder
  • JpaTransactionObservation call path

이 셋은 문서상 completion-evidence/operability의 핵심이지만 current root assembly에서 provider가 없다.

이 sibling comparison으로 “class가 있으니 feature가 있다”는 판단을 피했다.

29. documentation drift

transaction docs에는 현재 서로 다른 세 시대의 설계가 겹쳐 있다.

current source truth

application-core canonical:

PolicyTransactionPort -> SpringTransactionPort

public JPA runtime:

SpringJpaTransactionExecutor + FullTransactionRetryCoordinator bean도 별도 존재
JpaTransactionAutoConfiguration javadoc

canonical port와 coordinator가 같은 구현인 것처럼 설명 — current type graph와 불일치.

docs/jpa/transaction-guide.md

application service가 TransactionPort or JpaTransactionExecutor로 boundary를 연다고 설명 — 두 public usage model을 함께 유지하는 설명.

support-matrix.md / runbook

completion evidence Stable, platform records unknown, metric + reconciliation record가 있다고 설명 — current wiring과 불일치.

따라서 transaction documentation은 단순 오래된 class 이름 수준이 아니라 어떤 transaction model이 canonical인지와 Stable capability가 무엇을 실제 제공하는지를 재정렬해야 한다.

30. fresh verification과 실제 증명 범위

30.1 transaction/failure focused tests

fresh:

:adapter:outbound:persistence-jpa:test
  --tests transaction.*
  --tests failure.*
  --rerun-tasks

결과: BUILD SUCCESSFUL.

이 19개 dedicated test가 강하게 증명하는 것:

  • retry backoff math
  • commit failure classifier 자체의 commit-unknown 분류
  • completion record value construction
  • EvidenceAware manager 자체의 phase marking
  • nested evidence scope cleanup
  • FullTransactionRetryCoordinator 자체의 retry/no-retry decisions
  • CallBudget/timeout calculations
  • application policy port의 determinate/indeterminate/commit result
  • root-only tx rejection
  • SQLSTATE operational error mapping

그러나 custom manager의 production installation은 이 test들이 증명하지 않는다.

30.2 root wiring tests

fresh app-bootstrap:

  • JpaPlatformAddonAssemblyTest
  • JpaPlatformRuntimeAutoConfigurationTest
  • CapabilityEntityScanRegistrationTest

결과: BUILD SUCCESSFUL.

이들은:

  • old class-level @ConditionalOnBean(DataSource) ordering regression 방지
  • DataSource/PTM이 있을 때 executor/coordinator bean assembly
  • persistence root import/entity scan shape

를 증명한다.

하지만 PlatformTransactionManagerEvidenceAwareJpaTransactionManager인지, completion record/metric이 실제 transaction path에서 발생하는지는 assert하지 않는다.

30.3 real lost-ack qualification은 아직 아님

050 probe는 fake manager commit failure로 classification path를 isolate한 것이다.

실제 PostgreSQL server가 commit을 적용한 직후 client ack/network를 끊는 시나리오까지 재현한 것은 아니다. 그 수준의 evidence는 postgresqlIntegrationTest sub-scope에서 별도 qualification해야 한다.

31. transaction/failure findings backlog

P1 — completion-evidence Stable contract가 actual composition에 연결되지 않음
  • EvidenceAwareJpaTransactionManager implementation/test는 존재하지만 production construction 0.
  • normal manager commit 08006 probe -> CONNECTION_UNAVAILABLE, completionUnknown=false.
  • CompletionUnknownRecorder implementation/consumer 0.
  • JpaTransactionObservation record call 0; observability composition helper construction도 0.
  • support matrix/runbook은 Stable evidence + metric + reconciliation record를 약속.
  • canonical application path는 Indeterminate로 no-auto-retry safety는 유지하지만 durable reconciliation reference를 채우지 않음.
  • 우선순위: P1 reliability/operability, real DB lost-ack integration qualification 필요.
P2 — custom JpaRetryPolicy가 silently ignored
  • public composition overload가 application-supplied policy를 받음.
  • valid TransactionProfile은 retryProfile non-null 강제.
  • coordinator는 profile이 non-null이면 fallback/custom policy를 사용하지 않음.
  • runtime probe: customPolicyCalls=0, workCalls=2.
  • 우선순위: P2 functional contract.
P2 — canonical transaction boundary documentation과 실제 dual stack 불일치
  • docs/source comment는 coordinator가 PolicyTransactionPort를 구현한다고 설명.
  • 실제 구현체는 SpringTransactionPort.
  • coordinator/runtime bean은 별도로 계속 존재.
  • 우선순위: P2 architecture consistency / Decision 필요.
P3 — TransactionProfileRegistry legacy residue
  • declarative retry interceptor 제거 후 production consumer 0.
  • non-api implementation package.
  • 우선순위: P3 cleanup candidate.
Cross-scope candidate — JPA observability composition 전체 reachability
  • JpaObservabilityAutoConfiguration current production construction 0.
  • query/transaction/retry observation 중 일부가 별도 경로에서 살아 있을 수도 있으므로 observation owning sub-scope에서 다시 exhaustive 확인.
  • transaction scope에서는 completion-unknown runbook signal 부재 evidence로만 사용.

32. Sub-scope 03 완료 조건

확인한 것:

  • production 32 / 32 FULL_READ
  • dedicated tests 19 / 19 FULL_READ
  • unclassified 0
  • application canonical transaction implementation trace
  • public JPA executor/coordinator trace
  • retry/deadline/CallBudget algebra
  • completion evidence state machine
  • root/component-scan/conditional wiring
  • current public reachability
  • duplicate retry/failure mechanism comparison
  • documentation drift
  • declarative-retry migration history
  • custom-policy runtime probe
  • commit-ambiguity runtime classification probe
  • focused tests fresh
  • app-bootstrap wiring tests fresh

남긴 경계:

  • actual PostgreSQL lost-commit-ack network qualification
  • PostgreSQL SQLSTATE/constraint translator 전체 correctness
  • observation package 전체 reachability/metrics completeness
  • pool runtime behavior

이 후속 항목은 각각 vendor/integration/observation-performance sub-scope에서 다시 확인한다.


33. Sub-scope 04 — Spring Data + Hibernate + Querydsl

내부 상태: COMPLETE — 42 production + 11 dedicated test, 53 / 53 FULL_READ
범위: persistence/hibernate/**, persistence/springdata/**, persistence/querydsl/**와 matching dedicated tests
핵심 질문: JPA query/batch 최적화 helper가 실제로 어떤 비용 경계를 강제하는지, Stable/Advanced 기능이 runtime과 release evidence에서 어디까지 살아 있는지.

33.1 숫자 지도
package production dedicated test 역할
hibernate 22 2 provider policy, statistics, SQL naming, JDBC batch, bulk DML, StatelessSession
springdata 17 8 repository fragment, entity graph, sort, keyset, stream, Specification policy
querydsl 3 1 optional dynamic-query integration
합계 42 11 53

53개 source/test를 모두 FULL_READ했다. 이 scope의 integration semantics를 확인하기 위해 PostgreSQL-backed batch/ID strategy/collection-fetch contract, release-registry/task mapping, app-bootstrap architecture rule도 cross-scope evidence로 읽고 fresh 실행했다.

34. 이 sub-scope는 하나의 query framework가 아니라 세 단계의 정책층이다

현재 code shape는 대략 다음처럼 읽는 것이 맞다.

application-owned query/repository contract
        |
        v
springdata/**
  - allowlisted sort
  - keyset assembly/predicate
  - fetch-plan catalog
  - bounded stream lifetime
  - Specification safety
        |
        v
hibernate/**
  - provider/version facts
  - real Statistics/JDBC batch evidence
  - statement naming
  - batch/bulk/stateless provider optimization
        |
        +----------------------+
        |
        v                      v
JPA/Hibernate runtime       querydsl/**
                            optional Advanced helper

중요한 점은 springdataquerydsl이 application-core의 repository contract를 대체하는 generic CRUD layer가 아니라는 것이다. JpaRepositoryFragmentSupport에는 범용 save/findAll/delete가 없고, domain-owned adapter가 필요한 query mechanism만 조합하게 설계돼 있다.

이 방향은 support matrix의 “platform-owned generic CRUD repository는 unsupported”와 일치한다.

35. Hibernate provider policy는 declared baseline과 실제 runtime을 분리한다

HibernateProviderPolicy는 상수로 선언된 Stable provider baseline과 실제 classpath에서 읽은 runtime version을 구분한다.

이 설계가 필요한 이유는 repository가 과거 “7.4를 Stable baseline이라고 문서화하면서 실제 Spring Boot BOM은 7.1.x를 resolve”한 상태를 경험했기 때문이다.

현재는:

  • declared baseline: policy constant
  • runtime provider: org.hibernate.Version에서 읽음
  • drift 여부: driftsFromDeclaredBaseline()
  • app-bootstrap capability/report가 runtime value를 사용

으로 나뉜다.

즉 “문서 상수와 같은 상수를 assert해서 green”인 self-fulfilling test는 피한다.

이 sub-scope에서 outside-leaf production consumer가 명확히 존재하는 핵심 Hibernate type도 HibernateProviderPolicy다. app-bootstrap이 이를 composition/report에 사용한다.

36. 통계 수집은 configuration이 아니라 실제 실행 evidence를 보려 한다

HibernateStatisticsCollectorHibernateStatisticsSnapshot은 다음을 분리해서 측정한다.

  • prepared statements
  • entity loads/fetches
  • collection fetches
  • flushes
  • JDBC batches

특히 JDBC batch count를 Hibernate Statistics 값으로 추정하지 않고 JdbcBatchCounter를 별도 주입한다.

이것은 중요한 설계 선택이다.

hibernate.jdbc.batch_size = 50

은 batching을 “요청한 설정”이지 실제 driver/JDBC batch가 실행됐다는 증거가 아니다.

실제 PostgreSQL integration test도 delta.jdbcBatches()를 보고 sequence entity와 IDENTITY entity의 차이를 측정한다.

또 statistics가 disabled면 0을 반환하지 않고 실패한다. 0을 “실제 쿼리가 없었다”와 “측정 자체가 꺼져 있었다” 사이에서 공유하지 않는다.

37. batch executor — 과거 data-loss 회귀는 현재 수정돼 있다

HibernateJpaBatchExecutor의 기본 전략은:

persist
 -> 주기적 flush
 -> 주기적 clear
 -> final flush
 -> final clear

이다.

Persistence Context를 clear해야 heap growth를 제한할 수 있지만, flush하지 않은 managed entity를 clear하면 INSERT 자체가 사라질 수 있다.

과거 구현은 flushSize=100, clearSize=150처럼 경계가 어긋날 때 150번째 clear에서 101~150 rows를 detach해 버릴 수 있었다. executor는 processed=300을 반환하는데 DB에는 250 rows만 남는 형태였다.

현재 코드는:

if (flushDue || clearDue) {
    entityManager.flush();
}
if (clearDue) {
    entityManager.clear();
}

로 바뀌었다.

즉 clear는 항상 flush barrier를 동반한다.

PostgreSQL-backed HibernateJpaBatchExecutorIntegrationTest에도 다음 regression이 존재한다.

  • mismatched flush/clear boundary에서도 모든 rows 보존
  • clear가 flush보다 멀리 있어도 row loss 없음
  • flush size보다 적은 rows도 final flush로 보존
  • 중간 failure 시 전체 transaction rollback
  • active transaction 밖 batch 거부
  • real JDBC batch count > 1
  • Persistence Context max entity count bounded

이번 fresh 선택 실행에서도 이 integration class 7 tests가 skip/failure 없이 통과했다.

38. Confirmed P2 — property-access IDENTITY entity가 batch guard를 우회한다

HibernateBatchConfigurationGuard는 batching-required profile에서 GenerationType.IDENTITY를 거부한다.

그 이유 자체는 실제 PostgreSQL evidence가 있다.

  • sequence fixture: JDBC batches > 1
  • IDENTITY fixture: JDBC batches = 0

문제는 guard의 annotation 탐색 방식이다.

현재 usesIdentityGeneration(Class<?>)은 class hierarchy의 declared fields만 읽는다.

field @Id
 -> field @GeneratedValue
 -> strategy == IDENTITY ?

하지만 JPA는 field access뿐 아니라 property access도 허용한다. 즉 다음과 같은 mapping도 정상적인 JPA mapping이다.

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long getId() { ... }
실행 probe

getter에 @Id + @GeneratedValue(IDENTITY)를 선언한 entity class를 현재 compiled guard에 전달했다.

결과:

propertyIdentityDetected=false
propertyIdentityValidation=PASSED

batchingRequired=true profile이어도 legal property-access IDENTITY entity를 통과시킨다.

현재 repository production entity search에서는 property-access ID mapping이 확인되지 않았으므로 현재 shipped entity가 이 결함을 밟는 evidence는 없다.

그러나 이 class는 generic JPA platform guard이고, IDENTITY를 fail-closed로 거부한다고 문서화한다. 따라서 adopter가 property access를 사용하면 guard의 핵심 안전 계약이 깨진다.

우선순위: P2 — provider guard correctness / adoption safety

수정 방향:

  • JPA metamodel로 actual id attribute/access strategy를 해석하거나
  • field/getter 모두 검사하되 duplicate/override access 규칙까지 JPA access semantics와 맞추거나
  • 지원 mapping을 field access로 제한한다면 그 제한을 architecture rule로 강제

단순히 getter reflection을 추가하는 것만으로 mixed access/@Access까지 완전히 해결됐다고 보기는 어렵다.

Raw: evidence/raw/053-jpa-query-hibernate-boundary-probe.txt

39. BatchExecutionResult.batched()는 작은 실행에 false-negative가 있다

BatchExecutionResult javadoc은 jdbcBatches를 “batching happened at all”을 판단하는 값으로 설명한다.

그런데 convenience method는:

return jdbcBatches > 1L;

이다.

probe에서:

jdbcBatches=1
batched()=false

가 확인됐다.

다만 이것을 즉시 높은 우선순위 defect로 올리지는 않는다.

한 번의 executeBatch가 여러 statement를 묶었다면 “batching은 발생했다”고 말할 수 있지만, counter가 단지 executeBatch call count만 제공한다면 1만으로 그 batch에 몇 row가 묶였는지는 알 수 없다. 현재 real integration contract도 1,000-row run에서 jdbcBatches > 1을 강한 evidence로 사용한다.

따라서 현재 판단은:

  • P3 semantic/naming edge
  • batched()가 “at least one JDBC batch call”인지 “multiple measured batch executions”인지 API 의미를 명확히 할 필요

이다.

40. bulk DML과 StatelessSession은 일반 repository path와 다른 비용 모델을 명시한다

40.1 Hibernate bulk DML

HibernateBulkDmlExecutor는 arbitrary JPQL string을 아무 데서나 실행하는 helper가 아니다.

  • operation name 등록
  • affected-row expectation
  • persistence-context cleanup
  • transaction requirement

를 contract로 둔다.

bulk DML은 managed entity lifecycle을 우회하므로 ordinary entity save와 같은 audit/lifecycle guarantee를 기대하면 안 된다. support matrix도 이를 Advanced capability로 분리한다.

현재 production business consumer는 확인되지 않았고 PostgreSQL integration fixture에서 실제 behavior를 qualification한다. 따라서 “runtime에서 사용 중”이라고 주장하지 않는다.

40.2 StatelessSession

HibernateStatelessSessionRunner는 오히려 이 platform에서 transaction ownership 예외를 명시적으로 드러낸다.

일반 repository adapter:

application transaction boundary에 참여

StatelessSession runner:

새 StatelessSession
 -> 자체 physical transaction
 -> registered work만 허용
 -> affected row cap 확인
 -> 초과 시 rollback

과거 review에서는 caller가 선언한 maxRows가 실제 affected rows와 연결되지 않는 문제가 있었다. 현재는 StatelessWorkResult(value, affectedRows)를 요구하고 cap 초과 시 commit 전에 rollback한다.

즉 과거의 “이름만 row cap” 문제는 현재 코드에서 수정돼 있다.

41. Spring Data repository support는 generic CRUD보다 query execution policy에 가깝다

JpaRepositoryFragmentSupport는 domain-specific repository adapter가 사용할 공통 실행 support다.

제공하는 것은 대략:

  • EntityManager access
  • query name context
  • fetch plan application
  • bounded query observation scope

이고 범용 business repository contract는 제공하지 않는다.

이 구조는 Clean Architecture 관점에서 의미가 있다.

application-core가 JpaRepository, EntityManager, Specification을 알 필요가 없고, 실제 domain repository port를 구현하는 outbound adapter 내부에서만 Spring Data/JPA mechanics를 사용한다.

42. entity graph catalog는 EntityManager-affinity를 피한다

EntityGraphCatalog는 이미 만들어진 EntityGraph instance를 전역 보관하지 않는다.

대신 factory를 등록하고 현재 EntityManager에서 graph를 생성한다.

그 이유는 JPA graph/object가 provider/session/entity-manager lifetime에 묶일 수 있기 때문이다.

FetchPlanApplier는 registered fetch plan을 조회해서:

  • fetch graph
  • load graph

hint를 구분해 query에 적용한다.

이 역시 raw client path를 받지 않고 registered name을 통해 query behavior를 선택한다.

43. sort는 allowlist + total order를 강제한다

SafeSortMapper의 핵심 invariant는 두 개다.

  1. client field를 entity path로 그대로 넘기지 않는다.
  2. ordering 끝에 unique tie-breaker를 붙인다.
43.1 allowlist

SafeSortRegistry가 public sort name -> SafeSortField mapping을 가진다.

unknown name은 fail-closed다.

따라서 JpaSort.unsafe(clientString) 같은 raw ORDER BY path가 없다.

43.2 tie-breaker direction historical fix

과거 mapper는 request가 tie-breaker를 생략하면 무조건 DESC를 붙였다.

예:

createdAt ASC

요청이 실제로는:

createdAt ASC, id DESC

가 되었다.

이는 caller가 고르지 않은 mixed ordering이고 single-direction keyset logic과 충돌했다.

현재는 registry 자체가 tieBreakerDirection을 선언하고 mapper가 그 값을 사용한다.

이 regression은 현재 수정된 상태다.

44. keyset predicate는 mixed type / mixed direction을 표현하도록 진화했다

KeysetPredicateBuilder는 conjunction이 아니라 lexicographic predicate를 만든다.

예를 들어 (createdAt ASC, id DESC)라면 cursor 뒤는 개념적으로:

createdAt > cursorTime
OR
(createdAt = cursorTime AND id < cursorId)

이다.

현재 KeysetTerm<T>는 각 term마다:

  • expression
  • cursor value
  • direction

을 가진다.

그래서 (Instant, UUID)처럼 term type이 다르고 direction도 다른 ordering을 표현할 수 있다.

source history에는 과거 one-type/one-direction API가 mixed order에서 rows를 skip/repeat했던 이유가 주석으로 남아 있고, 현재 code/test는 이를 보완했다.

44.1 남는 contract boundary

builder는 “마지막 term이 unique tie-breaker여야 한다”고 문서화하지만 runtime에서 uniqueness를 증명할 metadata는 받지 않는다.

검사할 수 있는 것은:

terms.size() >= 2

정도다.

따라서 uniqueness는 caller/registry contract다. 현재 evidence만으로 이를 defect라 단정하지 않는다. platform이 이를 fail-closed invariant로 승격하려면 unique-key metadata까지 contract에 포함해야 한다.

45. keyset execution은 size + 1로 hasNext를 판정하고 count query를 제거한다

JpaKeysetQuerySupport는:

query.setMaxResults(page.fetchSize())  // size + 1
 -> result
 -> KeysetSliceAssembler

형태다.

반환은 최대 size개이고 추가 1개로 hasNext를 판단한다.

이 path에는 COUNT(*)가 없다.

즉 keyset을 도입해 OFFSET full-walk 비용을 줄여 놓고 total count로 다시 full-work를 추가하는 구조를 피한다.

실제 PostgreSQL readiness query도 (occurred_at,id) > (?,?) ORDER BY ... LIMIT ? 형태와 representative index 사용을 별도 integration lane에서 검증한다. 해당 entire integration lane 자체는 later sub-scope 11의 denominator이므로 여기서는 cross-scope evidence로만 사용한다.

46. stream helper는 resource lifetime을 return type shape로 제한한다

JpaStreamExecutor의 핵심은 Stream<T>를 외부로 반환하지 않는 것이다.

active read-only transaction 확인
 -> supplier가 stream open
 -> consumer에 bounded stream 전달
 -> consumer result 생성
 -> stream close
 -> query scope close
 -> result만 반환

ScrollPolicy.maxRows()stream.limit()에 적용하고 실제 소비 row count를 observation에 기록한다.

또 supplier type이 현재는:

Function<ScrollPolicy, Stream<T>>

이라 fetch-size policy가 query 생성 지점까지 전달될 수 있다.

과거에는 plain Supplier<Stream<T>>여서 executor가 가진 fetch-size가 stream-opening query에 도달하지 않는 문제가 있었고, 현재는 수정됐다.

reactive Publisher를 결과로 반환하는 것도 hierarchy name 기반으로 거부한다. Reactor/Reactive Streams dependency를 blocking JPA module compile classpath에 직접 추가하지 않고도 application-declared Publisher implementation까지 탐지하려는 방식이다.

47. Confirmed P2 — SpecificationPolicySpecification.unrestricted()를 bounded로 오인한다

SpecificationPolicy의 문서 계약은 명확하다.

a specification with no predicate is a full table scan wearing a builder's clothing

그리고 predicate가 없으면 explicit allow-unbounded-scan token이 필요하다고 설명한다.

하지만 구현은 다음만 확인한다.

if (specification == null && !allowToken) {
    reject
}

Specification object의 존재와 predicate의 존재를 동일시한다.

47.1 Spring Data 4.0.7 자체가 non-null unrestricted Specification을 제공한다

현재 resolve된 spring-data-jpa:4.0.7 bytecode를 확인했다.

Specification.unrestricted()
 -> non-null Specification lambda
 -> lambda toPredicate(...) returns null

따라서 이건 인위적인 edge case가 아니다. 현재 dependency가 공식적으로 제공하는 representation이다.

47.2 실행 probe
Specification<Object> noPredicate = (root, query, cb) -> null;
SpecificationPolicy.requireBounded(noPredicate, page, null);

결과:

nonNullNullPredicateSpecification=PASSED

이다.

즉 policy가 막겠다고 문서화한 predicate-free bounded-page full scan이 explicit opt-in 없이 통과한다.

현재 repository production consumer search에서는 SpecificationPolicy 사용자가 0이므로 shipped business path 영향은 관찰되지 않았다.

그러나 intended Spring Data safety helper로서 자기 계약을 만족하지 않는다.

우선순위: P2 — safety-contract correctness before adoption

주의할 점은 generic Specification을 실행 전에 평가해 predicate null 여부를 확인하려면 Criteria context가 필요하다는 것이다. 단순 reflection으로 해결하기 어렵다.

가능한 방향:

  • raw Specification을 safety boundary로 받지 않고 platform-owned bounded predicate descriptor를 사용
  • explicit “unrestricted” 여부를 caller가 별도 contract로 선언
  • repository execution helper 안에서 실제 Criteria predicate 생성과 policy validation을 결합

Raw:

  • evidence/raw/053-jpa-query-hibernate-boundary-probe.txt
  • evidence/raw/061-spring-data-specification-unrestricted-contract.txt

48. Querydsl integration은 production runtime classpath를 강제로 오염시키지 않는다

QuerydslJpaSupport는 Advanced opt-in으로 설계돼 있다.

build:

compileOnly 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
testImplementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'

lockfile에서 Querydsl은:

  • compileClasspath
  • test/integration/performance classpaths

에는 나타나지만 production runtimeClasspath configuration에는 포함되지 않는다.

따라서 JPA leaf를 사용하는 것만으로 Querydsl runtime dependency가 Stable deployment에 따라오는 구조는 아니다.

QuerydslJpaSupport도:

  • bounded page size <= 500
  • null predicate는 explicit unbounded opt-in 없으면 거부
  • registered QueryName을 Hibernate comment hint로 적용

한다.

현재 production consumer는 확인되지 않았다. 이는 Advanced opt-in helper의 미채택 상태로 기록하며 dead-code defect로 단정하지 않는다.

49. SQL query naming mechanism은 구현은 있으나 shipped composition wiring을 찾지 못했다

QueryNameContextNamedStatementInspector의 설계는 다음과 같다.

registered QueryName을 thread-local scope에 bind
 -> Hibernate StatementInspector
 -> SQL prefix/comment에 bounded query identity 추가

이렇게 하면 raw SQL text가 아니라 registered query identity로 DB statement와 application observation을 연결할 수 있다.

문제는 current production source/config 전체에서:

  • NamedStatementInspector construction = class definition 외 0
  • Hibernate statement_inspector property registration = 0

이라는 점이다.

JpaRepositoryFragmentSupportQueryNameContext를 사용하지만 실제 Hibernate statement inspector가 설치되지 않으면 그 name은 SQL layer까지 내려가지 않는다.

따라서 query/observability documentation에서 “registered query name이 generated SQL에 연결된다”는 설명은 current shipped composition evidence가 없다.

이것은 transaction scope에서 확인한:

  • JpaObservabilityAutoConfiguration production construction 0
  • transaction observation call path 0

와 같은 방향의 증거다.

Cross-scope finding: JPA observability capability의 구현 클래스들은 존재하지만 composition completeness가 부족하다.

최종 severity는 later observation/config owning sub-scope에서 전체 mechanism을 다시 읽고 확정한다. 여기서는 SQL query naming path가 현재 unwired라는 observed evidence만 추가한다.

50. 대부분의 optimization helper가 production에서 직접 소비되지 않는다는 사실은 이미 repository가 알고 있다

negative-space search에서 다음 implementation roots는 repository production consumer가 확인되지 않았다.

  • HibernateJpaBatchExecutor
  • JpaBatchProfileRegistry
  • HibernateBulkDmlExecutor
  • HibernateStatelessSessionRunner
  • FetchPlanApplier
  • JpaKeysetQuerySupport
  • JpaRepositoryFragmentSupport
  • JpaStreamExecutor
  • SpecificationPolicy
  • QuerydslJpaSupport

하지만 이것을 곧바로 “dead code가 대량 존재한다”라고 해석하면 안 된다.

이 repository의 기존 study/review 문서도 이미 JPA platform helper가 구현/qualification되어 있지만 sample production path가 대부분 채택하지 않은 상태라고 기록한다. 또한 batch/bulk/stateless helper는 real PostgreSQL integration tests에서 직접 실행된다.

따라서 현재 판단은 capability별로 나눈다.

implemented + qualified + not adopted

예:

  • batch
  • bulk DML
  • stateless

이들은 library capability로 유지할 수 있다.

implemented but production composition itself가 필요한데 wiring 없음

예:

  • NamedStatementInspector처럼 global Hibernate hook이 필요한 기능

이 경우는 “아무 use case가 안 쓴다”와 다르다. feature를 사용하려면 composition이 먼저 존재해야 한다.

old mechanism이 consumer 제거 후 남은 경우

transaction scope의 TransactionProfileRegistry처럼 history를 통해 실제 residue로 판정해야 한다.

grep refs=0은 finding의 시작점이지 결론이 아니다.

51. export boundary는 현재 split SSOT다

이 leaf는 하나의 jar 안에 많은 public implementation type이 존재한다. 그래서 “public Java modifier”와 “architecturally exported package”를 별도로 관리하려 한다.

51.1 leaf-local EXPORTED_PACKAGES

JpaModuleBoundaryTest에는 다음 export set이 있다.

  • api
  • notification.configuration
  • transaction
  • security
  • observation
  • migration
  • hibernate
  • fileserver
  • failure
  • config

springdata, querydsl은 여기 없다.

51.2 실제 app-bootstrap consumer rule은 별도 allowlist를 다시 가진다

CleanArchitectureTest.BOOTSTRAP_USES_ONLY_THE_PERSISTENCE_EXPORT_SURFACE는 또 다른 EXPORTED set을 정의한다.

여기에는 root composition이 vendor entry point를 import해야 하므로:

  • postgresql
  • h2

까지 추가돼 있다.

즉 두 목록은 이미 동일하지 않다.

51.3 leaf list 자체는 outside consumer를 검사하지 않는다

JpaModuleBoundaryTest의 local export test는:

  • export package가 실제 존재하는지
  • 새 top-level package가 governance 대상인지

를 보지만 repository의 outside consumer import를 직접 스캔하지 않는다.

실제 consumer restriction은 app-bootstrap의 별도 ArchUnit rule이 담당한다.

따라서 current architecture fitness function은:

leaf export declaration A
          X shared SSOT 아님
bootstrap allowed imports B

형태다.

fresh architecture tests는 모두 통과했다. 이것은 현재 import graph가 각자의 rule을 만족한다는 뜻이지 A와 B가 서로 drift하지 않는다는 증명은 아니다.

우선순위: P2/P3 architecture-governance hardening

권장 방향은 exported package registry를 한 곳으로 옮기고 leaf package DAG와 consumer ArchUnit rule이 같은 데이터를 읽게 하는 것이다.

52. Confirmed P1 — collection-fetch-pagination blocking release gate가 실제 위험을 증명하지 않는다

이번 sub-scope에서 가장 중요한 finding이다.

support matrix는 다음 gate를 blocking release gate로 선언한다.

collection-fetch-pagination

목적:

paged collection fetch가 전체 table을 읽고 memory에서 pagination하는 provider regression을 차단

이다.

이 위험의 특성상 returned page size는 증거가 아니다. provider가 모든 rows를 읽고 Java에서 20개만 반환해도 결과는 정확하기 때문이다.

실제 HibernateCollectionFetchPaginationContractTest javadoc도 정확히 이 점을 알고 있다.

assertion is therefore on the generated SQL, not on the returned page size

그러나 실제 test body는 그 설명을 구현하지 않는다.

52.1 실제 collection-fetch test가 SQL limit을 보지 않는다

oneCollectionPageIsBoundedInSql()의 핵심 assertion은:

returned page size <= expected max
expected.requiresDatabaseLimit() == true

뿐이다.

다음을 검사하지 않는다.

  • generated SQL의 LIMIT/FETCH FIRST/subquery shape
  • StatementInspector capture
  • query AST
  • provider warning/failure
  • hibernate.query.fail_on_pagination_over_collection_fetch

repository search에서도 해당 fail-on-pagination setting의 runtime configuration은 확인되지 않았다.

다른 같은 class tests도:

  • prepared statement count <= 2
  • N+1 comparison
  • fixture 전체 row amplification bound

을 검증할 뿐 parent selection이 SQL에서 제한됐는지 증명하지 않는다.

이번 real PostgreSQL fresh run에서 해당 class 4 tests는 전부 통과했다. 하지만 green은 현재 assertion이 green이라는 뜻이지 documented risk가 차단됐다는 뜻이 아니다.

52.2 release registry가 가리키는 producer task는 그 test를 실행하지도 않는다

더 큰 문제는 provenance mapping이다.

config/jpa/release-registry.json:

collection-fetch-pagination
 -> :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest

그런데 Gradle task registration은:

jpaPlatformQueryPlanTest
 -> includeTags "jpa-queryplan"

이다.

실제 collection-fetch class는:

@Tag("jpa-contract")

이고 PostgreSqlQueryPlanContractTest@Tag("jpa-queryplan")이다.

52.3 exact registry task fresh 실행 결과

이번에 registry가 지정한 task 자체를 fresh 실행했다.

결과:

BUILD SUCCESSFUL
executed XML suites:
  PostgreSqlQueryPlanContractTest

HibernateCollectionFetchPaginationContractTest는 이 task의 executed suite에 존재하지 않았다.

즉 release registry가 이 gate의 evidence producer라고 지목한 task가 gate scenario를 실행하지 않는다.

52.4 현재 gate-validator도 이 mismatch를 잡지 못한다

verifyJpaReleaseGateTasks는 registry의 각 gate에 대해:

  • absolute Gradle path인가
  • project가 존재하는가
  • task가 존재하는가
  • Test task인가

까지만 검사한다.

fresh 실행 결과:

verifyJpaReleaseGateTasks: OK — 6 gate task(s) resolve to real Test tasks.
BUILD SUCCESSFUL

이다.

즉 지금처럼 의미상 완전히 다른 tests를 실행하는 Test task도 valid producer로 인정한다.

52.5 aggregate release task가 collection test도 실행한다는 점은 mitigation이지 provenance fix가 아니다

jpaPlatformReleaseGate aggregate는 jpaPlatformContractTestjpaPlatformQueryPlanTest 둘 다 dependsOn 한다.

따라서 full aggregate를 실행하면 @Tag("jpa-contract")인 collection test 자체는 다른 lane에서 실행될 수 있다.

하지만 이것은 registry mapping을 올바르게 만들지 않는다.

  • gate별 evidence provenance가 틀림
  • gate task를 단독 재검증하면 target scenario 미실행
  • collection test가 실행돼도 target behavior assertion 부족

이므로 false evidence 문제가 두 겹이다.

52.6 역사
  • collection-fetch contract test: initial JPA platform commit 0e61f86에서 이미 현재 SQL-inspection 없는 shape로 추가
  • release registry gate mapping: 이후 integration commit 2f5d2fc에서 추가

즉 최근 refactor regression이라기보다 초기 evidence design부터 존재한 gap이다.

우선순위: P1 — blocking release evidence integrity

이유:

support matrix 자체가 release gate를 “tests가 pass해도 production에서 틀릴 수 있는 경우를 막기 위한 것”이라고 정의한다. 그런데 이 gate는 바로 그 종류의 false green을 허용한다.

수정 조건은 둘 다 필요하다.

  1. gate producer가 실제 collection-fetch scenario를 실행하도록 registry/task/tag 연결 수정
  2. test가 generated SQL 또는 fail-closed provider signal로 DB-side pagination을 직접 검증

둘 중 하나만 고치면 gate는 여전히 불완전하다.

Raw:

  • evidence/raw/056-persistence-jpa-collection-fetch-gate-provenance.txt
  • evidence/raw/057-persistence-jpa-query-hibernate-postgresql-contracts.txt
  • evidence/raw/058-persistence-jpa-queryplan-gate-task.txt
  • evidence/raw/059-verify-jpa-release-gate-tasks.txt
  • evidence/raw/060-persistence-jpa-query-hibernate-history.txt

53. 기존 review finding 중 현재 해결된 것과 남은 것을 분리한다

기존 docs/reviews/2026-08-14-jpa-module-code-review.md에는 이 영역의 여러 문제를 이미 지적했다.

현재 source와 대조하면 다음은 해결됨으로 관찰된다.

  • batch clear가 unflushed entity를 버리던 문제 -> clear 전 flush
  • stream fetch-size가 query supplier에 전달되지 않던 문제 -> Function<ScrollPolicy,...>
  • stateless row cap이 실제 affected rows와 연결되지 않던 문제 -> StatelessWorkResult.affectedRows
  • sort tie-breaker direction 고정 문제 -> registry-declared direction
  • keyset mixed type/direction 표현 문제 -> per-term type/direction

반면 이번에 확인한:

  • property-access IDENTITY guard bypass
  • Specification.unrestricted() bypass
  • collection-fetch release false evidence
  • StatementInspector composition 부재
  • split export SSOT

는 current snapshot에 남아 있다.

이 분리를 하지 않으면 과거 review의 defect를 현재 defect처럼 중복 보고하거나, 반대로 “이미 review했으니 해결됐다”고 잘못 가정하게 된다.

54. fresh verification과 증명 범위

54.1 dedicated unit tests

fresh command:

:adapter:outbound:persistence-jpa:test
  --tests hibernate.*
  --tests springdata.*
  --tests querydsl.*
  --rerun-tasks

결과:

BUILD SUCCESSFUL in 23s
18 actionable tasks: 18 executed

이 11 dedicated tests는 현재 helper behavior를 확인하지만 다음 새 경계는 포함하지 않는다.

  • property-access IDENTITY
  • non-null null-predicate Specification
  • one JDBC batch convenience semantics
  • release registry provenance
54.2 architecture tests

fresh:

  • JpaModuleBoundaryTest
  • app-bootstrap CleanArchitectureTest

결과:

BUILD SUCCESSFUL in 1m 48s
100 actionable tasks: 100 executed

현재 package DAG와 bootstrap import graph는 rules를 만족한다.

하지만 두 export allowlist가 같은 SSOT인지까지 검증하지 않는다.

54.3 selected real PostgreSQL contracts

fresh jpaPlatformContractTest에서 다음 classes를 직접 선택했다.

  • HibernateCollectionFetchPaginationContractTest: 4 tests
  • HibernateJpaBatchExecutorIntegrationTest: 7 tests
  • IdStrategyContractTest: 4 tests

총 15 tests:

skipped=0
failures=0
errors=0
BUILD SUCCESSFUL

이는 실제 PostgreSQL 위에서 current assertions가 통과함을 증명한다.

특히 batch/ID strategy evidence에는 의미가 크다. 반면 collection-fetch의 SQL-limit 부재는 assertion design 문제라 이 green 결과로 해소되지 않는다.

54.4 exact query-plan gate task

fresh jpaPlatformQueryPlanTest:

3 tests
PostgreSqlQueryPlanContractTest only
BUILD SUCCESSFUL

registry mapping mismatch를 runtime result XML까지 확인했다.

54.5 release-task existence validator

fresh verifyJpaReleaseGateTasks도 성공했다.

이 success는 오히려 validator limitation의 evidence다. task semantic coverage/tag를 검사하지 않기 때문이다.

55. Sub-scope 04 findings backlog

P1 — blocking collection-fetch-pagination release gate false evidence
  • registry producer = jpaPlatformQueryPlanTest
  • producer actual suite = PostgreSqlQueryPlanContractTest only
  • target collection-fetch class는 jpa-contract tag
  • target test 자체도 generated SQL limit을 검사하지 않음
  • current gate validator는 task existence/Test type만 검증해 mismatch를 허용
  • P1 release-evidence integrity
P2 — property-access IDENTITY가 batching-required guard를 우회
  • guard field annotation만 탐색
  • legal getter/property access entity probe가 usesIdentityGeneration=false
  • validation passes
  • current production entity exposure는 field access라 shipped-hit evidence 없음
  • P2 platform guard correctness
P2 — SpecificationPolicy가 unrestricted non-null Specification을 허용
  • current Spring Data 4.0.7 Specification.unrestricted()는 non-null + null predicate
  • policy는 object null만 검사
  • explicit allow token 없이 predicate-free scan 통과
  • current production consumer 0
  • P2 safety contract before adoption
Cross-scope P1/P2 — query SQL naming/observability composition 부재
  • NamedStatementInspector runtime registration 0
  • QueryNameContext는 존재하지만 SQL layer bridge가 확인되지 않음
  • transaction observation wiring gap과 함께 later observation/config scope에서 최종 판정
P2/P3 — export surface split SSOT
  • leaf export list와 app-bootstrap consumer list가 중복 정의되고 이미 다름
  • current tests pass하지만 두 목록 간 drift를 막는 single-source rule 없음
  • architecture governance hardening
P3/open — BatchExecutionResult.batched() one-batch semantics
  • jdbcBatches=1 -> false
  • method naming/javadoc 의미를 더 명확히 해야 함
  • current large-run integration evidence에는 영향 없음
acknowledged, not newly promoted defect — unadopted platform helpers
  • many springdata/hibernate/querydsl executors have no production business consumer
  • repository docs/review already record platform implementation vs sample adoption gap
  • integration qualification이 존재하는 helper도 있으므로 refs=0만으로 dead code라 하지 않음

56. Sub-scope 04 완료 조건

확인한 것:

  • production 42 / 42 FULL_READ
  • dedicated tests 11 / 11 FULL_READ
  • unclassified 0
  • package DAG / export policy
  • external production reachability
  • Querydsl production-runtime optionality
  • provider statistics / batch measurement
  • batch flush/clear lifecycle
  • ID generation guard
  • bulk/stateless execution model
  • repository fragment / fetch-plan mechanism
  • safe sort / keyset predicate / keyset slice
  • stream lifecycle / fetch-size path / reactive rejection
  • Specification safety contract
  • NamedStatementInspector/QueryName wiring
  • history against prior review
  • blocking release gate provenance
  • focused unit tests fresh
  • architecture tests fresh
  • selected PostgreSQL contracts fresh
  • exact registry query-plan task fresh
  • release-task validator fresh

남긴 경계:

  • PostgreSQL vendor-specific translator/native-query/type implementation 전체
  • complete PostgreSQL integration/readiness source set
  • full observation/config package composition
  • real provider behavior under an intentionally regressed collection-fetch pagination implementation

이 항목들은 각각 sub-scope 05, 06/11에서 다시 owning-scope 기준으로 확인한다.

57. Sub-scope 05 범위와 denominator

이번 sub-scope의 소유 범위는 PostgreSQL vendor 구현과 root vendor migration이다.

구분 범위 파일 수 판정
production Java src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/** 55 FULL_READ
dedicated unit test src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/** 9 FULL_READ
vendor migration src/main/resources/db/migration/postgresql/*.sql 9 FULL_READ
합계 73 73 / 73 FULL_READ

unclassified file은 0이다. evidence/raw/064-postgresql-vendor-manifest-reachability.txt에 현재 revision의 tracked blob과 production construction/reachability snapshot을 남겼다.

이 sub-scope는 PostgreSQL 전용 SQLSTATE/constraint translation, native write/COPY, work claiming, JSON/array/range support, owner-safe idempotency, same-store inbox, polling outbox와 vendor migration을 소유한다. 반면 전체 postgresqlIntegrationTest source set의 완전독해는 sub-scope 11이 소유한다. 여기서는 finding 검증에 필요한 정확한 integration lane만 실행했다.

58. PostgreSQL failure translation: SQLSTATE 분류는 맞지만 40003 의미가 translator에서 소실된다

PostgreSqlFailureClassifier는 PostgreSQL SQLSTATE를 bounded FailureCategory로 분류한다. serialization failure, deadlock, lock-not-available, constraint family, timeout, connection failure, schema/data 문제를 문자열 메시지가 아니라 SQLSTATE/structured server field 기준으로 다루는 방향은 적절하다. constraint 이름도 server error field에서 꺼내 catalog로 번역하므로 localized message parsing에 의존하지 않는다.

문제는 COMPLETION_UNKNOWN이다.

현재 PostgreSqlExceptionTranslator.translate()는 classifier 결과가 COMPLETION_UNKNOWN이어도 JpaFailureContextcompletionUnknown을 항상 false로 만들고, switch에서 COMPLETION_UNKNOWNUNKNOWN과 함께 일반 JpaPersistenceException(FailureCategory.UNKNOWN, ...)으로 강등한다.

직접 probe에서 SQLSTATE 40003은 다음처럼 변환됐다.

type=JpaPersistenceException
category=UNKNOWN
sqlState=40003
completionUnknown=false
retryable=false

여기서 단순 진단 정보만 사라지는 것이 아니다. 현재 DefaultJpaRetryPolicyTransactionCompletionUnknownException 또는 FailureCategory.COMPLETION_UNKNOWN을 가장 먼저 검사해 RECONCILE로 보낸다. 그런데 실제 translator를 통과시키면 focused policy probe 결과가 다음과 같다.

translated.category=UNKNOWN
translated.completionUnknown=false
decision.disposition=FAIL
decision.reason=failure was classified as non-retryable

재실행은 막지만, commit 결과를 확인해야 하는 reconciliation 경로도 잃는다. fail-closed라는 이유로 안전하다고 볼 수 없는 이유다. commit이 실제로 적용됐는지 알 수 없는 상태를 terminal failure로 바꾸면 caller는 설계된 recovery protocol을 실행할 근거를 잃는다.

현재 revision에서는 이 translator가 실제 composition에 들어온다. PostgreSqlPersistenceConfigVendorFailureTranslator를 제공하고, JpaPlatformRuntimeAutoConfiguration이 이를 SpringJpaTransactionExecutorPersistenceFailureTranslatorChain에 넣는다. 따라서 예전 리뷰의 “vendor translator caller 없음” 문제는 현재 wiring에서 해소됐지만, 그 결과 40003 의미 손실은 이제 실제 transaction path에 도달 가능한 문제다.

2026-08-14 JPA review도 completion-unknown에 대해 body replay 0회 + reconciliation key 보존을 기대 계약으로 이미 기록했다. 따라서 이 finding은 새로운 정책 제안이 아니라 기존 recovery contract와 현재 구현 간 불일치다.

판정: P1 — production completion-unknown recovery contract violation.

필요한 수정 방향은 40003/COMPLETION_UNKNOWNTransactionCompletionUnknownException 또는 최소한 FailureCategory.COMPLETION_UNKNOWN + completionUnknown=true로 보존하고, translator → retry policy까지 한 테스트에서 RECONCILE을 고정하는 것이다.

59. PostgreSQL Idempotency V2: owner/CAS 구조는 강하지만 replay 경계가 두 군데 어긋난다

PostgreSqlOwnerSafeIdempotencyStore는 row lock, owner token, attempt, state revision, operation id와 transition digest를 결합해 claim/renew/fail/complete를 보호한다. renewmarkFailed는 동일 operation id replay에서도 semantic argument를 digest에 넣어 SAME_ARGUMENTSDIFFERENT_ARGUMENTS를 분리한다. 이 구조 자체는 강하다.

현재 revision에서는 PostgreSqlIdempotencyProviderConfig가 이 store를 production provider로 실제 생성하므로 아래 두 finding은 dormant helper 문제가 아니다.

59.1 P1 — inspect()claim()이 만료된 COMPLETED row를 동시에 다른 상태로 해석한다

inspect()는 row가 COMPLETED이고 response payload가 있으면 replayUntil이 이미 지난 값인지 확인하지 않고 무조건 COMPLETED_REPLAY를 반환한다. 반면 claim path는 DB time과 expiry를 보고 만료된 row를 takeover 가능 상태로 처리한다.

실제 PostgreSQL 16에서 replay TTL 25ms로 완료한 뒤 50ms를 기다린 probe 결과:

expiredInspect.outcome=COMPLETED_REPLAY
expiredInspect.replayUntil=<already expired>
expiredInspect.claimAfterExpiry=TakenOverClaimed

즉 같은 시점의 같은 row가:

inspect -> "이전 응답을 replay하라"
claim   -> "이전 replay window는 끝났으니 새 실행을 소유할 수 있다"

로 갈린다.

Application의 IdempotencyExecutorV2는 reconciliation에서 COMPLETED_REPLAY를 실제 저장 응답 반환 신호로 사용한다. 따라서 이 불일치는 단순 introspection 문제가 아니라 만료 후 새 실행이 허용된 시점에도 이전 응답을 reconciliation 결과로 반환할 수 있는 lifecycle correctness 문제다.

JPA 설계 문서가 동일 Idempotency V2 contract를 구현한다고 참조하는 Redis state machine도 COMPLETED -> [*] : replay TTL expires로 수명을 끝낸다. JPA inspect()만 이 만료를 무시한다.

판정: P1 — production idempotency lifecycle/reconciliation inconsistency.

수정 시 inspect()도 claim과 같은 DB-time 기준 expiry semantics를 사용해야 하며, replayUntil <= dbNow 이후에는 더 이상 COMPLETED_REPLAY를 반환하지 않는 real-PostgreSQL boundary test가 필요하다.

59.2 P2 — complete()의 replay 판정이 replayTtl 변경을 무시한다

첫 completion에서는 transition digest에 다음이 들어간다.

  • transition kind
  • operation id
  • owner tuple
  • response digest
  • replayTtl.toMillis()

코드 주석도 “completion이 replay window도 결정하므로 transition digest에 포함해야 한다”고 설명한다.

하지만 이미 COMPLETED인 동일 operation replay branch는 full transition digest를 비교하지 않고 operation id + response digest만 비교한다. 따라서 response는 같고 replay TTL만 바뀌면 ALREADY_COMPLETED_SAME_RESULT가 나온다.

실제 PostgreSQL probe:

completeReplayTtl.first=COMPLETED
completeReplayTtl.secondDifferentTtl=ALREADY_COMPLETED_SAME_RESULT
completeReplayTtl.storedSeconds=3600

첫 호출은 1시간, 두 번째 호출은 동일 operation/response에 9시간을 전달했다. 두 번째 호출은 semantic argument가 다른데도 same-result로 판정됐고 DB에는 최초 1시간 window가 그대로 남았다.

IdempotencyDigestPolicyTest는 이미 “replay window가 다르면 completion digest가 다르다”는 정책을 테스트한다. 또한 같은 integration test suite의 renew/markFailed는 동일 operation id + 다른 TTL/retention을 conflict로 검증한다. complete만 대응하는 replay-argument test가 빠져 있다.

판정: P2 — production idempotency replay semantic mismatch.

동일 operation replay에서도 첫 적용과 같은 complete transition digest를 계산해 replayTtl까지 비교해야 한다.

60. Same-store inbox / polling outbox: 구현 계약은 강하지만 현재 미조립 candidate에 replay holes가 있다

PostgreSqlSameStoreInboxAdapterPostgreSqlPollingDeliveryAdapterapplication-core의 owner-safe transition contract를 구현하지만, 현재 production composition에서 bean construction이나 stereotype은 확인되지 않았다. 따라서 아래 finding은 현재 배포 기본 경로의 즉시 장애가 아니라, 이 candidate adapter를 채택할 때 활성화되는 latent defect로 분리한다.

60.1 P2 latent — inbox markProcessing() duplicate replay가 owner 검증보다 먼저 persisted owner를 반환한다

markProcessing()은 같은 START + operationId를 발견하면 classifyMismatch()보다 먼저 owner(row)를 반환한다. 이 때문에 scope/operation id만 맞춘 forged owner로 replay하면 DB에 저장된 실제 owner token을 돌려받을 수 있다.

실제 PostgreSQL probe:

inboxForgedReplay.outcome=PROCESSING_STARTED
inboxForgedReplay.returnedActualToken=true
inboxForgedReplay.returnedForgedToken=false
inboxForgedReplay.completeWithReturnedOwner=COMPLETED

즉 duplicate handling이 owner capability recovery oracle처럼 동작한다. 채택 전에는 duplicate replay에서도 persisted owner tuple/revision과 supplied owner를 먼저 검증하도록 고쳐야 한다.

60.2 P2 latent — inbox retry/dead replay digest가 retention을 포함하지 않는다

markRetryable/markDeadretention은 실제 SQL update에는 들어가지만 transition digest에는 들어가지 않는다.

inboxRetention.first=RETRYABLE
inboxRetention.secondDifferentRetention=ALREADY_APPLIED_SAME_OPERATION
inboxRetention.remainingHours=1.000

동일 operation id로 retention만 바꾼 replay가 same-operation으로 흡수된다. retention은 terminal row 보존 기간을 결정하는 semantic argument이므로 digest에 canonical millis를 포함해야 한다.

60.3 P2 latent — outbox retry replay digest가 nextAttemptAt을 포함하지 않는다

markRetryable()nextAttemptAt을 DB에 기록하지만 transition digest는 kind + operation + owner + errorCode만 포함한다.

outboxRetry.first=RETRY_SCHEDULED
outboxRetry.secondDifferentSchedule=ALREADY_APPLIED_SAME_OPERATION
outboxRetry.storedEqualsFirst=true
outboxRetry.storedEqualsSecond=false

재시도 시각은 delivery scheduling 자체를 바꾸는 semantic argument다. 동일 operation replay consistency를 주장하려면 canonical instant를 digest에 넣어야 한다.

61. Native write, COPY, work claiming, JSON/array/range support

61.1 확인된 안전 경계

native write와 COPY는 caller가 임의 SQL identifier를 조립하도록 두지 않고 registered statement/name boundary를 사용한다. 값은 JDBC parameter 또는 COPY stream으로 전달된다. COPY에는 format/size bound와 transaction requirement가 있고, work claiming은 등록된 queue definition과 PostgreSQL FOR UPDATE ... SKIP LOCKED 경계를 사용한다.

JSON path/query support와 range query support도 registry/typed value boundary를 두고 실제 값은 bind한다. constraint translation 역시 structured SQLSTATE/server fields를 사용한다.

이번 sub-scope에서 이 영역의 새로운 SQL-injection/runtime-wiring defect는 확인되지 않았다.

61.2 P2 latent — PgRangeCodec이 자신이 escape한 quote를 다시 parse하지 못한다

format()은 endpoint 내부 "\를 escape한다. 그런데 separatorIndex()는 backslash escape를 고려하지 않고 모든 " 문자를 quote-state toggle로 취급한다.

self round-trip probe 결과:

comma.roundTrip=true
quote.error=IllegalArgumentException:postgresql range literal has no endpoint separator
quote-comma.error=IllegalArgumentException:range lower bound exceeds upper bound
backslash-quote-comma.error=IllegalArgumentException:range lower bound exceeds upper bound

즉 comma만 포함한 endpoint는 통과하지만 escaped quote가 포함되면 formatter가 만든 literal조차 parser가 읽지 못한다. 현재 PgRangeTest는 timestamp 중심이라 이 grammar boundary를 덮지 않는다.

현재 production consumer는 정적 reachability에서 확인되지 않았으므로 P2 latent helper algebra defect로 둔다. 채택 전에는 PostgreSQL quoted-range grammar에 맞게 escaped quote/backslash를 인식하는 tokenizer/state machine과 round-trip property test가 필요하다.

62. Vendor migrations

다음 9개 migration을 모두 읽었다.

V1__idempotency_record.sql
V3__outbox_event.sql
V4__int_lock.sql
V5__int_lock_expired_after.sql
V6__capability_schema_registry_adoption.sql
V9__widen_capability_schema_stream.sql
V10__idempotency_request_hash_varchar.sql
V11__durable_operation.sql
V12__live_event_log.sql

확인한 경계는 다음과 같다.

  • idempotency owner/state/replay/transition metadata의 persisted shape
  • outbox claim/delivery/index shape
  • integer advisory/row-lock support table와 expiry extension
  • capability schema registry adoption/widening
  • request hash char/varchar drift 보정
  • durable operation / live-event log schema

real PostgreSQL probe에서 Flyway는 vendor 9 migrations를 모두 validate/apply했다. 이번 sub-scope에서 migration 순서, 현재 schema 제약, index 선언 자체로 승격할 신규 defect는 확인하지 못했다. capability-specific migration의 완전한 cross-stream adoption은 각 owning capability scope에서 다시 본다.

63. Production reachability와 이전 리뷰 대비 변화

현재 revision에서 reachability는 동일하게 취급하면 안 된다.

구현 현재 production composition 판정 의미
PostgreSqlExceptionTranslator 있음 — vendor failure bean → JPA transaction executor chain 40003 finding은 production reachable
PostgreSqlOwnerSafeIdempotencyStore 있음 — PostgreSQL idempotency provider config TTL/replay findings는 production reachable
PostgreSqlSameStoreInboxAdapter 확인 안 됨 findings는 latent candidate
PostgreSqlPollingDeliveryAdapter 확인 안 됨 findings는 latent candidate
PostgreSqlImmutableOutboxAppendAdapter 확인 안 됨 candidate implementation
PgRangeCodec current production consumer 확인 안 됨 finding은 latent helper

이 구분은 중요하다. 2026-08-14 review에서는 vendor translator와 PostgreSQL idempotency store의 composition 부재가 별도 finding이었다. 이후 commit에서 runtime auto-configuration/provider wiring이 추가되어 그 “미조립” 문제 일부는 해결됐다. 따라서 현재 분석은 과거 finding을 그대로 복사하지 않고 현재 revision의 wiring 이후 실제 semantics를 다시 판정했다.

history와 prior-review exact-term snapshot은 evidence/raw/068-postgresql-vendor-history-review-provenance.txt에 남겼다.

64. Fresh verification evidence

64.1 PostgreSQL replay semantic probe

evidence/raw/062-postgresql-replay-semantic-probe.txt

  • PostgreSQL 16.15 Testcontainers
  • vendor migration 9개 validate/apply
  • inbox forged-owner replay
  • inbox changed-retention replay
  • outbox changed-nextAttemptAt replay
64.2 SQLSTATE 40003

evidence/raw/063-postgresql-40003-probe.txt

  • direct translator classification
  • 40003 -> UNKNOWN
  • completionUnknown=false

evidence/raw/069-postgresql-40003-policy-probe.txt

  • production translator → DefaultJpaRetryPolicy
  • 최종 decision.disposition=FAIL
  • expected recovery branch인 RECONCILE에 도달하지 못함
64.3 Range escaped-quote round trip

evidence/raw/065-pg-range-escaped-quote-probe.txt

  • current source의 PgRange/PgRangeCodec만 격리 compile
  • comma는 통과
  • escaped quote / quote+comma / backslash+quote+comma 실패
64.4 Idempotency real-PostgreSQL TTL boundaries

evidence/raw/066-postgresql-idempotency-replay-boundary-probe.txt

  • exact postgresqlIdempotencyIntegrationTest lane
  • complete() changed replay TTL false-same replay 재현
  • expired COMPLETED row의 inspect()/claim() lifecycle 불일치 재현
  • temporary test는 실행 후 source에서 복원
  • BUILD SUCCESSFUL
64.5 Dedicated PostgreSQL unit test full fresh rerun

evidence/raw/067-persistence-jpa-postgresql-unit-tests.txt

9 dedicated test classes를 --rerun-tasks로 실행했고 BUILD SUCCESSFUL이다.

이 green 결과는 current assertions의 통과를 증명하지만, 위 finding들의 boundary assertions가 기존 suite에 없다는 사실을 해소하지 않는다.

65. Sub-scope 05 findings backlog

우선순위 finding 현재 reachability
P1 SQLSTATE 40003 completion-unknown이 translator에서 UNKNOWN으로 강등되어 retry policy가 RECONCILE 대신 FAIL production
P1 만료된 COMPLETED idempotency row를 inspect()COMPLETED_REPLAY, claim()은 takeover 가능으로 동시에 해석 production
P2 idempotency complete() replay가 changed replayTtl을 same-result로 흡수 production
P2 latent inbox markProcessing() duplicate replay가 owner mismatch 검증 전에 persisted owner를 반환 candidate/uncomposed
P2 latent inbox retry/dead replay digest가 retention을 누락 candidate/uncomposed
P2 latent outbox retry replay digest가 nextAttemptAt을 누락 candidate/uncomposed
P2 latent PgRangeCodec escaped quote round-trip 실패 current production consumer 미확인
이번 scope에서 finding으로 승격하지 않은 항목
  • registered native write/COPY의 SQL/value boundary
  • work-claim SKIP LOCKED 기본 구조
  • structured SQLSTATE/constraint-name 추출
  • array/json helper의 bounded value handling
  • polling outbox cutover sentinel의 transition별 반복 검사 차이: claim 자체가 immutable sentinel을 요구하고 current evidence만으로 stale claim이 cutover를 우회한다고 입증되지 않아 보류
  • vendor migration 9개의 현재 적용 순서/문법

66. Sub-scope 05 완료 조건

확인한 것:

  • production Java 55 / 55 FULL_READ
  • dedicated unit Java 9 / 9 FULL_READ
  • vendor migration SQL 9 / 9 FULL_READ
  • total 73 / 73 FULL_READ
  • unclassified 0
  • PostgreSQL SQLSTATE/constraint translation
  • current vendor translator production composition
  • owner-safe idempotency implementation + production provider composition
  • same-store inbox / polling-outbox replay semantics + current non-composition 확인
  • registered native write / COPY / work claim
  • JSON / array / range support
  • vendor migration chain
  • prior review 및 key-file history
  • real PostgreSQL replay probes
  • 40003 translator → retry-policy end-to-end policy probe
  • idempotency replay-TTL/expiry integration probes
  • dedicated PostgreSQL unit tests fresh rerun
  • temporary source probes 모두 복원

남긴 경계:

  • baseline capability stores/config/audit/cache/H2 등 나머지 production surface: sub-scope 06
  • fileserver persistence: sub-scope 07
  • notification persistence: sub-scope 08
  • experimental platform: sub-scope 09
  • testkit/fixture: sub-scope 10
  • PostgreSQL integration/readiness source set 75 files의 완전독해: sub-scope 11
  • pool/performance: sub-scope 12

따라서 Sub-scope 05는 COMPLETE로 닫는다. JPA module 전체는 아직 IN_PROGRESS다.

67. Sub-scope 06 범위와 denominator

이번 sub-scope는 baseline persistence capability와 그 주변의 configuration/audit/cache/H2/operation/live-event/outbox/security 구현을 소유한다. evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt에 고정한 denominator는 다음과 같다.

구분 파일 수 판정
production Java 61 FULL_READ
dedicated unit Java 19 FULL_READ
capability migration SQL 7 FULL_READ
합계 87 87 / 87 FULL_READ

이번 범위는 audit, auditing, cache, envers, h2, idempotency, liveevent, lock, migration, observation, operation, outbox, security와 baseline configuration을 포함한다. PostgreSQL vendor-specific 구현은 sub-scope 05에서 이미 닫았고, complete PostgreSQL integration source set은 sub-scope 11이 소유한다. 여기서는 finding 검증에 필요한 real-PostgreSQL lane만 선택 실행했다.

68. Baseline composition을 먼저 분리해야 하는 이유

JpaAdapterComponentsConfig는 adapter 전체를 넓게 scan하지 않고 다음 package만 명시적으로 component scan한다.

  • audit
  • failure
  • idempotency
  • lock
  • outbox
  • transaction

따라서 같은 leaf 안에 있어도 reachability가 다르다.

  • OutboxStoreAdapter는 baseline scan에 들어가고 app-bootstrapOutboxConfigOutboxStorePort로 사용한다.
  • DurableOperationStoreAdapter, JpaLiveEventReplayAdapter는 현재 baseline component scan에 들어가지 않고 별도 production constructor/reference도 확인되지 않았다.
  • HibernateCacheGuard, HibernateEnversHistoryReader와 Spring Data auditing candidate도 default composition에 들어가지 않는다.
  • runtime-role verifier 자체는 app-bootstrap bean으로 구성되지만, policy를 적용하는 requireSafe() caller가 없다.

이 차이 때문에 아래 finding은 production, conditional-production, latent를 분리해 판정한다. 정적 composition snapshot은 evidence/raw/072-baseline-capability-reachability.txt에 남겼다.

69. P1 — Stable runtime-role verification이 startup에서 실제 policy를 적용하지 않는다

문서 계약은 명확하다. docs/jpa/security.md는 runtime role이 allowlist 밖이거나 schema/database CREATE를 가지면 startup이 실패한다고 적고, platform design도 startup verifiercurrent_user, search_path, schema privilege를 검사한다고 정의한다. JpaPlatformAutoConfiguration.capabilities()RUNTIME_ROLE_VERIFICATION을 Stable로 광고한다.

구현에는 policy가 존재한다.

  • PostgreSqlRuntimeRoleVerifier.verify()current_user, search_path, schema/database CREATE privilege를 읽는다.
  • DatabaseRolePolicy.requireSafe()는 approved role, CREATE privilege, SearchPathPolicy를 검사한다.
  • PostgreSqlRuntimeRoleVerifier.requireSafe(dataSource, policy)는 둘을 연결한다.

하지만 production composition에서 이 마지막 경로가 호출되지 않는다. JpaPlatformRuntimeAutoConfiguration.jpaPlatformStartupCheck()가 refresh 시 실행하는 것은 JpaDangerousConfigurationGuard.validate(environment)뿐이며, runtime database role policy는 받지도 않는다. repository 전체 production source에서 DatabaseRolePolicy를 생성하거나 requireSafe()를 호출하는 caller 역시 없다. 해당 호출은 unit/integration test에만 존재한다.

더 나쁜 점은 actuator semantics다. JpaPlatformReport.sanitized()runtimeRoleVerified를 다음 한 조건으로 계산한다.

privileges != null && !privileges.holdsCreatePrivilege()

wrong role name이나 unsafe search_path는 검사하지 않은 채 runtimeRoleVerified=true가 될 수 있다. 문서가 말하는 “role passed verification”과 실제 boolean 의미도 다르다.

결과적으로 현재 Stable capability는:

startup fail-fast policy -> 미조립
actuator verification     -> CREATE privilege 일부만 확인

상태다.

판정: P1 — production security/runtime-composition contract violation.

필요한 수정 방향은 app-bootstrap이 실제 DatabaseRolePolicy/SearchPathPolicy를 구성해 startup InitializingBean에서 roleVerifier.requireSafe(dataSource, policy)를 실행하고, actuator의 runtimeRoleVerified도 동일 policy 결과를 기반으로 계산하도록 SSOT를 하나로 만드는 것이다. startup negative composition test는 wrong role, CREATE privilege, unapproved search_path를 각각 포함해야 한다.

70. P1 conditional-production — baseline outbox는 stale relay worker를 fence하지 못해 terminal state를 되돌릴 수 있다

baseline outbox는 현재 composition에 실제 들어간다. OutboxStoreAdapterJpaAdapterComponentsConfigoutbox scan 대상이고, OutboxConfigca-skeleton.outbox.enabled=true일 때 OutboxStorePortPublishPendingOutboxEventsUseCase에 전달한다. relay까지 켜면 다음 흐름이 된다.

Tx1: claimBatch()
  -> row IN_FLIGHT
  -> next_attempt_at = now + inFlightTimeout
commit

outside transaction: broker publish

Tx2: markPublished / markFailed / markDead

PostgreSQL claim query의 FOR UPDATE SKIP LOCKED동시에 claim하는 순간만 직렬화한다. timeout이 지나면 IN_FLIGHT row도 다시 claim 가능하다. 그런데 baseline entity에는 owner token/claim revision이 없고, markPublished(eventId), markFailed(eventId, retryAt), markDead(eventId)는 event id로 row를 다시 읽어 현재 owner/attempt/state를 조건 없이 변경한다.

따라서 다음 race가 가능하다.

  1. worker A가 attempt 1을 claim하고 broker I/O에서 오래 멈춘다.
  2. visibility timeout이 지난 뒤 worker B가 같은 row를 attempt 2로 claim한다.
  3. B가 publish 성공 후 PUBLISHED로 mark한다.
  4. 늦게 돌아온 A의 failure path가 markFailed()를 호출한다.
  5. 이미 PUBLISHED인 row가 FAILED로 되돌아가 다시 delivery 대상이 된다.

focused probe에서 실제 adapter transition은 다음과 같이 재현됐다.

outboxStaleWorker.before=PUBLISHED
outboxStaleWorker.after=FAILED
outboxStaleWorker.retryAt=2026-06-11T10:00:30Z

즉 이 문제는 일반적인 at-least-once의 “publish 성공 후 DB mark 실패” window와 별개다. 새 worker가 소유권을 이어받은 뒤에도 stale worker가 새 상태/terminal 상태를 덮어쓸 수 있는 fencing 부재다.

판정: P1 conditional-production — app.outbox.enabled + relay 사용 시 delivery state corruption / duplicate publication risk.

V2 PostgreSqlPollingDeliveryAdapter에는 owner-safe transition 개념이 있지만 현재 default composition에 들어오지 않는다. baseline V1을 유지한다면 claim owner/attempt revision을 persisted state에 포함하고 모든 terminal/retry update를 CAS 조건으로 막아야 한다. 최소 regression은 stale attempt가 newer attempt 또는 PUBLISHED state를 변경하지 못함을 real PostgreSQL에서 고정해야 한다.

Evidence: evidence/raw/075-outbox-stale-worker-state-regression-output.txt, 075a-outbox-stale-worker-state-regression-probe.java.

71. P1 latent — durable operation은 lease가 만료돼도 takeover 전 stale owner가 완료할 수 있다

DurableOperationJpaRepository의 주석은 state-changing statement가 owner를 확인하고, lease를 잃은 worker가 결과를 기록하지 못해야 한다는 fencing contract를 설명한다. heartbeat()은 실제로 lease_expires_at > :now를 조건에 포함한다.

반면 reportProgress(), succeed(), fail()은 다음만 확인한다.

  • operation id
  • state = RUNNING
  • lease owner

lease expiry 자체는 확인하지 않는다.

따라서 takeover가 아직 일어나 owner 문자열이 바뀌지 않은 짧은 window에서는 lease를 이미 잃은 worker가 상태를 확정할 수 있다. real PostgreSQL 16 probe 결과:

durableExpiredLease.completionAt=2026-08-25T09:02:01Z
durableExpiredLease.leaseExpiredAtCompletion=true
durableExpiredLease.succeedUpdatedRows=1
durableExpiredLease.finalState=SUCCEEDED

기존 stale-worker test는 새 worker가 takeover해 owner가 이미 달라진 뒤를 검증하므로 이 expiry-after / takeover-before 경계를 덮지 않는다.

현재 DurableOperationStoreAdapter는 baseline component scan 및 다른 production constructor에서 확인되지 않았으므로 즉시 production reachable로 분류하지 않는다.

판정: P1 latent — durable-operation adapter 채택 시 lease fencing contract violation.

수정 시 progress/succeed/fail에도 DB time 기준 lease_expires_at > now를 적용하거나 claim revision/fencing token을 도입해야 한다. Evidence: evidence/raw/073-durable-operation-expired-lease-output.txt, 073a-durable-operation-expired-lease-probe.java.

72. P2 latent — live-event stream이 전부 sweep되면 position high-water mark가 사라져 position 1을 재사용한다

JpaLiveEventReplayAdapter는 sweep 뒤에도 position을 재사용하지 않아 cursor가 과거 event와 새 event를 혼동하지 않는다고 설명한다. 그러나 append allocator는 LiveEventJpaRepository.highestEverAssigned(streamId)를 사용하고, 이 query는 별도 high-water metadata가 아니라 **현재 live_event_log row의 max(position)**을 계산한다.

부분 sweep에서는 마지막 row가 남아 있으므로 문제가 숨는다. stream의 모든 row가 retention sweep으로 삭제되면 max(position)null이 되고 allocator는 다시 1부터 시작한다.

real PostgreSQL 16 probe:

liveEventFullSweep.swept=1
liveEventFullSweep.highestAfterSweep=null
liveEventFullSweep.nextPosition=1
liveEventFullSweep.payloadAtReusedPosition=new-event

기존 sweptPositionsAreNotReused() contract test는 allocator를 호출하지 않고 test fixture가 직접 position을 지정하므로 이 경계를 검증하지 않는다.

현재 JpaLiveEventReplayAdapter 역시 baseline production composition에서 확인되지 않았다.

판정: P2 latent — live-event adapter 채택 시 monotonic cursor/position invariant violation.

수정은 stream별 durable high-water row/sequence를 sweep 대상과 분리하거나, 삭제되어도 allocation state가 보존되는 구조가 필요하다. Evidence: evidence/raw/071-liveevent-full-sweep-probe-output.txt, 071a-liveevent-full-sweep-probe.java.

73. 이번 sub-scope에서 finding으로 올리지 않은 항목

73.1 H2 idempotency와 V2 owner 필드

처음에는 H2IdempotencyClaimRepository의 MERGE/takeover가 V2 owner/transition field를 초기화하지 않는 점을 의심했다. 그러나 baseline IdempotencyRecordEntity 자체가 V1 field만 mapping하고, owner-safe V2는 PostgreSQL capability stream으로 분리돼 현재 별도 activation contract를 가진다. 서로 다른 schema generation의 field를 H2 V1이 reset하지 않는 것은 현 계약 위반이 아니다.

73.2 auditauditing 두 경로

manual AuditableEntity/AuditContextPort 경로와 Spring Data AuditMetadata/JpaAuditingConfiguration이 함께 존재하지만 tests/docs가 후자를 candidate/dormant로 명시하고 default composition도 canonical manual audit 경로만 사용한다. 현재 중복 활성화 defect로 판정하지 않는다.

73.3 cache / Envers

HibernateCacheGuard, HibernateEnversHistoryReader는 Advanced/opt-in surface이며 default bean construction이 없다. 이 sub-scope에서 production mis-wiring으로 올릴 근거는 없었다.

74. Fresh verification evidence

  • evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt — 87-file exact denominator
  • evidence/raw/072-baseline-capability-reachability.txt — baseline package scan, runtime-role policy caller absence, outbox composition, latent adapter construction snapshot
  • evidence/raw/071-liveevent-full-sweep-probe-output.txt — real PostgreSQL full-sweep position reuse
  • evidence/raw/073-durable-operation-expired-lease-output.txt — real PostgreSQL expired lease completion
  • evidence/raw/075-outbox-stale-worker-state-regression-output.txt — baseline outbox terminal-state regression
  • evidence/raw/076-persistence-jpa-baseline-unit-tests.txt — fresh full :adapter:outbound:persistence-jpa:test --rerun-tasks BUILD SUCCESSFUL

세 probe 모두 temporary source replacement를 shell trap으로 복원했고 실행 뒤 code repository git status --short는 clean이었다.

75. Sub-scope 06 findings backlog

우선순위 finding 현재 reachability
P1 Stable runtime-role verification이 startup에서 DatabaseRolePolicy/SearchPathPolicy를 적용하지 않고 actuator도 CREATE privilege 일부만으로 verified 판단 production
P1 baseline outbox stale worker가 newer/terminal state를 owner fencing 없이 덮어쓸 수 있음 conditional-production (outbox relay enabled)
P1 latent durable operation lease 만료 후 takeover 전 stale owner가 progress/succeed/fail 가능 adapter currently uncomposed
P2 latent live-event full sweep 후 high-water mark 소실로 position 재사용 adapter currently uncomposed

Sub-scope 06은 87 / 87 FULL_READ + targeted runtime verification 완료로 닫는다. 다음 owning unit은 sub-scope 07 Fileserver persistence + migrations 29개다.

76. Sub-scope 07 범위와 denominator

Fileserver persistence의 owning denominator는 evidence/raw/077-persistence-jpa-fileserver-manifest.txt로 고정했다.

구분 파일 수 판정
production Java 25 FULL_READ
Fileserver migration SQL 4 FULL_READ
합계 29 29 / 29 FULL_READ

구현 범위는 file/upload/verification/quota/cleanup/recovery entity와 repository, JPA adapters, schema activation, V1~V4 migration을 포함한다. postgresqlIntegrationTest source set 자체의 denominator는 sub-scope 11이 소유하지만, 이 sub-scope에서 발견한 Fileserver semantic boundary를 검증하기 위해 해당 real-PostgreSQL lane을 선택적으로 실행했다.

77. Fileserver composition과 schema lifecycle

Fileserver persistence는 latent helper가 아니라 실제 opt-in production capability다.

  • PersistenceJpaRootAutoConfigurationFileserverJpaPersistenceConfig를 import한다.
  • app.fileserver-platform.enabled=true이면 Fileserver entity/repository/component scan이 열린다.
  • FileserverStorageConfiguration.fileserverSchemaActivation()JdbcOperations가 있으면 startup에서 requireActive()를 호출한다.
  • 따라서 schema activation, quota, cleanup, recovery adapter는 Fileserver capability가 켜진 배포에서 production-reachable하다.

V1은 registry에 jpa-fileserver-metadata-v1, feature_revision=1, INSTALLED_INACTIVE를 기록하고, V2는 recovery schema를 추가한 뒤 revision을 2로 올린다. 이후 V3는 fenced cleanup lease column을, V4는 upload terminal lifecycle column을 추가하지만 registry revision은 더 이상 갱신하지 않는다. 이 차이는 아래 startup fail-open finding의 직접 원인이다.

78. P1 — persistent byte quota가 실제 admission에서 집행되지 않는다

Fileserver 설계와 deviation 문서는 quota를 단순 accounting이 아니라 scope별 byte enforcement로 설명한다.

  • docs/fileserver/design-deviations.md는 quota decision이 scope별 reserved + committed 합을 사용한다고 명시한다.
  • implementation plan은 DB conditional update로 quota byte를 보호하고, scope limit 초과를 QuotaExceededException으로 매핑한다고 정의한다.
  • DefaultTransferAdmissionController의 class-level 설명도 “scope over its ceiling”을 QUOTA_EXCEEDED라고 표현한다.

하지만 production call graph에는 그 ceiling이 없다.

  1. DefaultUploadApplicationService.create()는 namespace를 QuotaScope로 만든 뒤 admissionController.acquireUpload(scope, bytes)를 호출한다.
  2. admission controller가 검사하는 것은 단일 파일 최대 크기, global storage high-water, JVM-local scope/instance semaphore뿐이다.
  3. 그 다음 JpaFileQuotaService.reserve()는 byte aggregate나 limit을 조회하지 않고 QuotaReservationEntity를 unconditional save()한다.
  4. production source에서 reservedBytes(scope) / committedBytes(scope) 또는 repository의 aggregate query를 quota decision에 사용하는 caller는 0개다.
  5. FileserverPlatformSettings.Quota에도 byte ceiling/tenant capacity가 없고 concurrency permits와 storage high-water만 있다.

즉 현재 DB quota ledger는 사용량 기록은 하지만 그 사용량을 기반으로 admission을 거절하지 않는다. namespace/tenant가 얼마나 많은 byte를 이미 예약·commit했든, 단일 파일 크기와 global storage high-water/동시성만 통과하면 새 reservation이 생성된다.

이는 단순 naming 문제가 아니다. per-scope quota는 multi-tenant resource isolation 경계인데, 현재 구현은 이를 JVM-local concurrent-upload 제한으로 대체하고 있다. 여러 인스턴스 배포에서는 scope semaphore 자체도 instance-local이다.

판정: P1 production cross-scope contract violation — persistent scope/tenant byte quota enforcement missing.

수정 방향은 persistent quota와 transfer concurrency를 분리해야 한다.

  • explicit scope/tenant byte ceiling policy를 둔다.
  • reserve/extendcommitted + live reserved + delta <= ceiling을 DB에서 원자적으로 보장해야 한다.
  • 단순 SUM() 후 INSERT는 concurrent reservation race가 있으므로 scope별 aggregate row lock/CAS, advisory lock, 또는 동일 수준의 serialized invariant가 필요하다.
  • JVM semaphore는 local concurrency guard로 유지하되 durable byte quota의 대체물이 되어서는 안 된다.
  • regression은 두 인스턴스가 limit 직전에서 동시에 reserve하는 case, expired reservation 제외, committed usage 포함, unknown-length extend, cleanup reclaim을 포함해야 한다.

Static evidence: evidence/raw/079-fileserver-reachability-quota-schema-contract.txt.

79. P1 conditional-production — schema activation이 V2를 current schema로 오인한다

FileserverSchemaActivation의 목적은 주석 그대로 첫 user request에서 missing relation/column 500이 나기 전에 startup에서 fail closed하는 것이다. 그러나 현재 gate는 다음만 요구한다.

capability_id = jpa-fileserver-metadata-v1
core_epoch = 1
feature_revision >= 2
lifecycle_state = ACTIVE

문제는 current code가 V2보다 뒤의 schema를 필요로 한다는 점이다.

  • V3: fs_cleanup_item.claim_owner, claim_token, lease_until, claim_fence
  • V4: fs_upload_session.lifecycle_state

그런데 V3/V4는 registry revision을 올리지 않는다. 따라서 V2까지만 적용된 DB를 ACTIVE로 promote하면 현재 gate를 통과한다.

이를 PostgreSQL 16에서 별도 database로 재현했다. base/core migration 후 Fileserver Flyway를 target 2까지만 적용하고 registry를 ACTIVE로 만든 결과:

fileserverSchemaV2.featureRevision=2
fileserverSchemaV2.activationAccepted=true
fileserverSchemaV2.cleanupClaimToken=false
fileserverSchemaV2.uploadLifecycleState=false

즉 startup activation은 성공했지만 현재 cleanup/upload repository가 요구하는 V3/V4 column은 존재하지 않았다.

이 문제는 Hibernate validate가 항상 구해주는 것도 아니다. JpaDangerousConfigurationGuardTest가 production에서도 spring.jpa.hibernate.ddl-auto=none을 허용하도록 고정하고 있기 때문이다. 이 profile에서는 activation이 사실상 deployment fail-fast gate인데 현재 V2를 허용한다.

판정: P1 conditional-production — Fileserver enabled + schema V2 ACTIVE + ddl-auto=none에서 startup fail-open / first-use SQL failure risk.

이미 V3/V4가 배포된 migration history가 있을 수 있으므로 기존 migration 파일의 checksum을 바꾸는 방식은 피해야 한다. 안전한 수선은 새 forward migration에서 current schema revision marker를 올리고 activation이 그 revision 이상을 요구하게 하는 것이다. 그 뒤 V2 ACTIVE database가 startup에서 거부되는 regression을 고정해야 한다.

Evidence: evidence/raw/081-fileserver-schema-activation-v2-output.txt, 081a-fileserver-schema-activation-v2-probe.java.

80. P2 — quota reclaim은 최대 64개 committed row만 처리하고 남은 byte를 조용히 버린다

JpaQuotaReclaimGateway.reclaim(scope, bytes)findCommittedWithBytes(scope, Limit.of(64))한 번만 조회한다. 그 64개 row를 모두 소진한 뒤에도 outstanding > 0이면 추가 page/query를 하지 않고 method가 끝난다.

real PostgreSQL에서 동일 scope에 1-byte committed row 65개를 만든 뒤 65 bytes reclaim을 요청한 결과:

fileserverQuotaReclaim.before=65
fileserverQuotaReclaim.requested=65
fileserverQuotaReclaim.after=1

physical delete가 성공한 뒤 cleanup service가 이 gateway를 호출하므로, 64개보다 많은 ledger row에 걸친 reclaim은 실제 사용량보다 committed accounting을 높게 남긴다. 현재 byte quota enforcement가 빠져 있어 즉시 admission rejection으로 이어지지는 않지만, ledger 자체가 quota/reclamation SSOT라는 계약을 위반하고 향후 enforcement가 복구되면 capacity leak로 직결된다.

판정: P2 production accounting correctness defect.

수정은 outstanding이 0이 될 때까지 bounded page를 반복하되 forward progress를 보장하거나, scope aggregate usage를 별도 row로 유지해 reclaim을 O(1) CAS로 만드는 편이 낫다. “최대 64개만 처리”를 의도한 batch boundary라면 caller가 remainder를 재-enqueue해야 하지만 현재 그런 contract는 없다.

Evidence: evidence/raw/078-fileserver-quota-boundary-probe-output.txt, 078a-fileserver-quota-boundary-probe.java.

81. P2 — direct FileQuotaService.commit()은 만료 reservation을 commit한다

JpaFileQuotaService의 own Javadoc은 “already expired or released reservation can never be extended or committed”라고 명시한다. extend() query는 실제로 expiresAt > now를 조건으로 둔다.

반면 FileserverQuotaRepository.commit()status='RESERVED'만 확인하고 expiry predicate가 없다. real PostgreSQL에서 reservation의 expires_at을 과거로 이동한 뒤 public FileQuotaService.commit()을 호출하면:

fileserverExpiredQuota.status=COMMITTED
fileserverExpiredQuota.committedBytes=600

으로 전환됐다.

여기서는 수정 경계를 주의해야 한다. 별도 JpaQuotaCommitGateway는 upload가 TTL보다 오래 걸렸더라도 실제 durable byte를 under-count하지 않기 위해 expired upload usage를 기록하는 의도적 path를 가진다. 따라서 shared repository commit()에 무조건 expiry predicate를 추가하면 그 settlement contract까지 깨질 수 있다.

판정: P2 production API-contract defect.

수정은 “live reservation direct commit”과 “expired upload durable usage settlement”를 별도 SQL/API로 분리해 전자는 expiry를 엄격히 거부하고 후자는 명시적 recovery/settlement 의미로 유지해야 한다.

Evidence: evidence/raw/078-fileserver-quota-boundary-probe-output.txt.

82. P2 — recovery queue의 enqueue()는 concurrent upsert가 아니다

JpaRecoveryQueue는 Javadoc에서 enqueue를 upsert라고 정의하고 “same file reported twice updates the open item rather than adding a second one”이라고 설명한다. 구현은:

UPDATE existing PENDING
if updated == 0:
    INSERT new PENDING

이고 DB에는 WHERE status='PENDING' partial unique index가 있다. 최초 item이 없는 상태에서 두 transaction이 동시에 들어오면 둘 다 UPDATE 0을 보고 INSERT로 진행할 수 있다. unique index는 duplicate row는 막지만 loser transaction을 정상 upsert로 흡수하지는 않는다.

real PostgreSQL concurrent probe 결과:

fileserverRecovery.concurrentFailures=1
fileserverRecovery.first=SUCCESS
fileserverRecovery.second=org.springframework.dao.DataIntegrityViolationException
fileserverRecovery.rowCount=1

recovery enqueue는 finalize의 ambiguous commit path와 reconciliation worker 양쪽에서 production 호출되므로 동일 file에 대한 동시 report가 가능한 seam이다. 한 row만 남는 DB invariant는 지켜지지만 “enqueue request가 durable work item으로 합쳐진다”는 adapter contract 대신 caller 하나가 persistence exception을 받는다.

판정: P2 production concurrency/idempotency defect.

PostgreSQL native upsert가 partial unique predicate와 동일 semantics를 갖도록 구성하거나, insert unique conflict를 잡아 bounded update retry로 수렴시켜야 한다. regression은 barrier를 둔 two-transaction 최초 enqueue에서 둘 다 성공하고 open row는 1개임을 검증해야 한다.

Evidence: evidence/raw/080-fileserver-recovery-concurrent-enqueue-output.txt, 080a-fileserver-recovery-concurrent-enqueue-probe.java.

82.1. P2 — cleanup crash-reclaim은 MAXIMUM_ATTEMPTS를 우회해 poison item을 무한 재시도할 수 있다

JpaCleanupQueueMAXIMUM_ATTEMPTS = 8을 두고, 정상적인 markFailed() 경로에서는 item.attempt() + 1 >= 8이면 ABANDONED로 전환한다. class Javadoc도 반복 실패한 poison item을 계속 재시도하지 않는 것이 이 queue의 명시적 계약이라고 설명한다.

그러나 worker crash는 다른 경로를 탄다. DefaultCleanupService.runBatch()는 매 batch 시작 시 reclaimExpiredClaims()를 먼저 호출하고, FileserverCleanupRepository.reclaimExpiredClaim()은 expired IN_PROGRESS row를 항상 다음 상태로 되돌린다.

status = FAILED
attempt = attempt + 1
last_error_code = CLAIM_LEASE_EXPIRED
claim fields = null

여기에는 MAXIMUM_ATTEMPTS 또는 현재 attempt에 대한 terminal 조건이 없다. 따라서 worker가 physical cleanup 중 계속 crash하면 정상 실패 budget을 거치지 않고 lease expiry → reclaim → claim → crash를 반복할 수 있다.

real PostgreSQL에서 claim 후 settlement 없이 lease expiry만 9회 반복한 결과:

fileserverCleanupCrash.maxAttempts=8
fileserverCleanupCrash.actualAttempt=9
fileserverCleanupCrash.status=FAILED
fileserverCleanupCrash.lastError=CLAIM_LEASE_EXPIRED

즉 명시된 최대 8회를 넘겼는데도 row는 ABANDONED가 아니라 다시 claim 가능한 FAILED로 남았다. 이는 cleanup queue의 poison-item bounded retry 계약을 깨고, 반복적으로 crash를 유발하는 cleanup item이 scheduler capacity를 계속 소비하게 만든다.

판정: P2 production liveness / bounded-retry defect.

수정은 crash-reclaim과 normal failure가 동일한 attempt budget을 공유하게 해야 한다. reclaimExpiredClaim()에서 증가 후 attempt가 limit에 도달하면 ABANDONED로 전환하거나, repository가 next-state를 caller로부터 받되 DB CAS가 token과 attempt를 함께 검증하도록 구성할 수 있다. regression은 normal failure와 crash-reclaim을 섞어도 총 attempt budget을 넘으면 반드시 terminal ABANDONED가 되는지 고정해야 한다.

Evidence: evidence/raw/079-fileserver-cleanup-crash-budget-output.txt, 079a-fileserver-cleanup-crash-budget-probe.java.

83. 이번 sub-scope에서 finding으로 올리지 않은 항목

83.1 quota FIFO settlement 자체

reservation row가 upload id와 연결되지 않아 JpaQuotaCommitGateway가 scope의 가장 오래된 live reservation부터 정산하는 것은 docs/fileserver/design-deviations.md에 명시적으로 기록된 adaptation이다. row identity와 실제 upload identity가 1:1이 아닌 것 자체는 현재 설계 계약이다. 다만 그 문서가 전제로 둔 aggregate byte enforcement가 실제로 없다는 점은 §78의 별도 P1 finding으로 올렸다.

83.2 cleanup fenced lease의 expiry-after / takeover-before window

cleanup settlement query는 claim token을 fence하고 reaper takeover가 token을 교체한다. lease expiry 직후 아직 takeover 전인 worker가 settle할 수 있는 window는 보이지만, 새 owner가 생긴 뒤 stale worker가 상태를 덮어쓰는 race는 token CAS가 막는다. durable-operation과 달리 현재 계약만으로 “expiry 순간부터 절대 settle 금지”라고 확정할 충분한 근거가 없어 finding으로 올리지 않았다.

83.3 과거 JPA-028 cleanup fencing finding

이전 review의 Fileserver cleanup owner/token/terminal-state 부재는 V3/V4와 현재 repository code에서 실제로 보완돼 있다. 이번 분석은 그 과거 finding을 중복 집계하지 않는다.

84. Fresh Fileserver verification evidence

  • evidence/raw/077-persistence-jpa-fileserver-manifest.txt — 25 production Java + 4 migration SQL, 29 / 29 FULL_READ
  • evidence/raw/079-fileserver-reachability-quota-schema-contract.txt — production quota readers/ceiling absence, admission logic, schema revision/activation snapshot
  • evidence/raw/078-fileserver-quota-boundary-probe-output.txt — real PostgreSQL expired commit + 65-row reclaim truncation
  • evidence/raw/080-fileserver-recovery-concurrent-enqueue-output.txt — real PostgreSQL concurrent recovery enqueue race
  • evidence/raw/081-fileserver-schema-activation-v2-output.txt — real PostgreSQL V2-only ACTIVE activation acceptance
  • evidence/raw/082-fileserver-official-readiness-lanes.txt — original-source Fileserver migration/metadata/reclamation no-skip lanes, fresh --rerun-tasks, BUILD SUCCESSFUL, 21/21 tasks executed, git clean before/after
  • evidence/raw/079-fileserver-cleanup-crash-budget-output.txt, 079a-fileserver-cleanup-crash-budget-probe.java — real PostgreSQL에서 crash-reclaim만으로 attempt 9 / FAILED를 재현해 8회 poison budget 우회를 확인

기존 temporary probe source는 실행 후 원본으로 복구했다. cleanup crash-budget probe는 Gradle test 자체는 BUILD SUCCESSFUL / exit 0이었지만 wrapper의 restore trap이 cd src 뒤 상대경로를 사용해 복원 단계만 실패했다. 직전 clean snapshot의 exact HEAD blob을 해당 analysis-owned test file 하나에 다시 기록한 뒤 worktree hash와 HEAD hash가 동일함을 확인했고, 최종 git status --short는 clean이었다.

85. Sub-scope 07 findings backlog

우선순위 finding reachability
P1 persistent reserved + committed byte quota를 실제 admission에서 읽거나 ceiling과 비교하는 경로가 없음 production when Fileserver enabled
P1 schema activation이 revision 2 ACTIVE를 허용하지만 current code는 V3/V4 columns를 요구 conditional-production; especially ddl-auto=none
P2 reclaim이 64 committed rows 이후 remainder를 처리하지 않아 usage accounting이 남음 production
P2 public direct quota commit이 expired RESERVED row를 COMMITTED로 전환 production
P2 concurrent first recovery enqueue 중 한 transaction이 unique violation으로 실패 production
P2 cleanup crash-reclaim이 MAXIMUM_ATTEMPTS=8을 적용하지 않아 attempt 9+도 FAILED로 재활성화됨 production

Sub-scope 07은 29 / 29 FULL_READ + targeted real-PostgreSQL boundary verification 완료로 닫는다. 다음 owning unit은 sub-scope 08 Notification persistence + migrations 68개다.

86. Sub-scope 08 범위와 denominator

Notification persistence의 owning denominator는 evidence/raw/083-persistence-jpa-notification-manifest.txt로 고정했다.

구분 파일 수 판정
production Java 53 FULL_READ
Notification migration SQL 10 FULL_READ
dedicated unit Java 5 FULL_READ
합계 68 68 / 68 FULL_READ

범위는 notification schema activation/facade, payload/contact-point crypto, request/recipient/attempt/policy/admin/reconciliation/provider-event/inbox entities·repositories·stores, V1~V10 opt-in migration을 포함한다. postgresqlIntegrationTest notification classes는 sub-scope 11 denominator에 남겨 두되, 이번 finding의 DB semantics를 검증하기 위해 기존 contract/readiness lane과 별도 PostgreSQL probe를 선택적으로 실행했다.

87. Notification composition과 schema lifecycle

Notification JPA capability는 production opt-in path로 실제 composition된다.

  • PersistenceJpaRootAutoConfigurationNotificationJpaPersistenceFacade를 import한다.
  • facade가 NotificationJpaPersistenceConfig를 import하고 entity/repository/store bean을 조립한다.
  • application-side worker/config가 recipient lease, reconciliation, provider-event ledger, admin operation store를 실제 소비한다.
  • NotificationSchemaActivation은 capability registry를 읽어 startup activation을 검사한다.

schema stream은 V1V10까지 진화했지만 registry는 V4에서 jpa-notification-platform-v4, feature_revision=4, INSTALLED_INACTIVE를 기록한 뒤 더 이상 revision을 올리지 않는다. 반면 current Java mapping과 SQL은 V5V10에서 추가된 column/constraint에 실제 의존한다. 이 drift가 §88의 startup false-positive를 만든다.

88. P1 conditional-production — V4 ACTIVE schema가 current V10-compatible schema로 오인된다

NotificationSchemaActivation은 다음 조건이면 capability를 active로 인정한다.

capability_id = jpa-notification-platform-v4
core_epoch = 1
feature_revision >= 4
lifecycle_state = ACTIVE

하지만 current code는 revision 4 이후 migration을 요구한다. 대표적으로:

  • V5: recipient expires_at
  • V6: delivery-attempt projection facts/version + suppression side-effect claim state
  • V7: request collapse fields
  • V8: admin command_fingerprint, phase, claimed_at
  • V9: provider execution-evidence certainty fields
  • V10: protected payload envelope constraint

V5~V10 어느 migration도 capability registry revision을 5 이상으로 올리지 않는다.

PostgreSQL 16에서 core V1/V2 + Notification V1~V4만 적용하고 registry row를 ACTIVE로 promote한 뒤 activation SQL을 그대로 실행했다.

activation_count = 1
feature_revision = 4
lifecycle_state = ACTIVE

동시에 current code가 요구하는 column 존재 여부는 다음과 같았다.

notification_recipient_delivery.expires_at                = false
notification_delivery_attempt.projection_version          = false
notification_request.collapse_key                         = false
notification_admin_audit.phase                            = false
notification_delivery_attempt.request_started_certainty   = false

즉 startup gate는 성공하지만 first-use 시 current repository/entity SQL과 DB schema가 맞지 않을 수 있다. 기존 postgresqlNotificationSchemaActivationIntegrationTest는 fresh rerun으로 green이지만, 그 green은 현재 gate가 정의한 V4 lifecycle을 검증할 뿐 V10 mapping compatibility를 증명하지 않는다.

판정: P1 conditional-production schema fail-open. Notification capability가 켜진 상태에서 V4까지만 적용된 DB가 ACTIVE라면 startup이 current schema 부재를 잡지 못한다.

기존 V1~V10 migration checksum을 수정하기보다 새 forward migration에서 current schema revision을 명시적으로 올리고 activation이 그 revision 이상을 요구하게 하는 편이 안전하다. regression은 V4 ACTIVE를 반드시 거부하고 current revision만 허용해야 한다.

Evidence: evidence/raw/084-notification-schema-v4-activation-probe.txt, 089-notification-schema-official-readiness.txt.

89. P1 — provider 호출 뒤 recipient projection write가 lease fencing을 우회한다

recipient lease 설계 자체는 owner + monotonic fence를 갖는다. claim은 lease_fence를 증가시키고 stillHeld() / renewLease()lease_until > now까지 검사한다. 문제는 provider side effect 이후 결과를 저장하는 실제 production path다.

NotificationDispatchService.dispatch() 흐름은 다음이다.

  1. provider call 직전에 leases.stillHeld(lease)를 확인한다.
  2. provider call은 transaction 밖에서 실행한다.
  3. call이 돌아오면 DispatchOutcomeRecorder.record(...)를 write transaction에서 실행한다.
  4. recorder는 attempt result를 저장한 뒤 recipient projection에 **recipients.save(updated)**를 호출한다.
  5. save()는 row를 현재 시점에 ID로 다시 읽고 projection field를 변경할 뿐 owner/fence/expiry를 검증하지 않는다.

따라서 lease가 provider call 도중 만료되거나 다른 worker가 takeover해도 stale caller의 결과 write가 자동으로 거부되지 않는다.

실제 fenced helper도 완전하지 않다. saveProjectionHeldBy() / transitionHeldBy()id + lease_owner + lease_fence만 조건으로 두고 lease_until > now는 확인하지 않는다. PostgreSQL에서 이미 만료되어 stillHeld 조건이 0건인 row에 동일 owner/fence write를 실행하면 UPDATE 1이었다.

더 강한 takeover case도 재현했다.

# worker B가 takeover한 직후
state=DISPATCHING, owner=worker-b, fence=8, version=1

# stale worker A의 recorder/save와 동등한 ID-only projection write 뒤
state=RECONCILIATION_REQUIRED, owner=worker-b, fence=8, version=2

즉 새 owner의 lease identity는 그대로인데 이전 provider call의 stale outcome이 recipient state를 덮을 수 있다. 이는 fencing token을 둔 목적과 직접 충돌한다.

판정: P1 production concurrency/correctness defect. provider side effect와 authoritative outcome write 사이의 lease handoff에서 stale writer가 살아남는다. 결과에 따라 중복 전송 위험 판단, retry/reconciliation state, attempt count가 새 holder의 흐름과 충돌할 수 있다.

수정은 provider completion 이후의 authoritative recipient mutation을 반드시 RecipientLease에 결박해야 한다. 최소한 owner + fence + lease_until > completedAt/now를 하나의 conditional write에서 검증하고, 0-row update는 superseded result로 처리해야 한다. recorder가 일반 save()를 호출하는 구조도 제거하거나 lease-aware recorder API로 바꿔야 한다.

Evidence: evidence/raw/085-notification-expired-lease-write-probe.txt, 091-notification-stale-provider-overwrite-probe.txt, 092-notification-reachability-test-gap.txt.

90. P2 — reconciliation FOR UPDATE SKIP LOCKED는 worker 처리 구간을 claim하지 않는다

JdbcReconciliationJobStore.claimDue()는 due row를 다음 SQL로 읽는다.

SELECT ...
FROM notification_reconciliation_job
WHERE next_check_at <= ?
ORDER BY next_check_at, id
LIMIT ?
FOR UPDATE SKIP LOCKED

그러나 이 method는 별도 transaction boundary를 열지 않고 durable owner/status/lease도 기록하지 않는다. ReconciliationJobWorker.reconcileOnce()claimDue() 뒤 provider reconciliation을 수행한 다음에야 complete() 또는 reschedule()을 호출하며 전체 구간을 감싸는 TransactionPort/@Transactional이 없다.

따라서 normal JdbcTemplate autocommit에서는 SELECT가 반환되는 순간 row lock이 풀린다. PostgreSQL에서 worker A의 claim SELECT가 끝난 뒤 A가 아직 complete/reschedule하지 않은 상태를 유지하고 worker B가 같은 SQL을 실행하자 두 호출 모두 같은 job을 반환했다.

worker A -> job 5555... attempts=0
worker B -> job 5555... attempts=0
row state -> next_check_at unchanged, attempts=0, last_result=null

SKIP LOCKED 자체가 잘못된 것이 아니라 lock lifetime과 work lifetime이 다르다. 현재 형태는 동시에 SELECT statement를 실행하는 아주 짧은 순간만 중복 read를 피하고 provider 조회/정산 중복을 막지 못한다.

판정: P2 production multi-instance coordination defect. reconciliation은 send 자체가 아니라 provider 상태 조회/상태 projection이어서 recipient dispatch P1보다 영향도를 낮게 잡지만, 두 worker가 같은 job을 처리할 수 있다는 class contract는 깨진다.

수정은 delivery claim처럼 durable owner/fence/lease를 기록하는 short claim transaction을 두거나, 전체 processing을 DB lock transaction 안에 두어야 한다. 외부 provider call을 긴 DB transaction에 넣는 것은 피하는 편이 좋으므로 전자가 더 적합하다.

Evidence: evidence/raw/086-notification-reconciliation-claim-probe.txt, 092-notification-reachability-test-gap.txt.

91. P2 — V8 atomic admin claim은 production service에 연결되지 않았고 completion 모델도 미완성이다

V8 migration과 AdminOperationStorePort.claim()의 설명은 문제를 정확히 알고 있다. 기존 find -> act -> save 구조에서는 두 caller가 모두 빈 상태를 읽고 같은 operation id의 action을 실행할 수 있으므로 INSERT ... ON CONFLICT DO NOTHING으로 먼저 claim해야 한다는 설계다.

하지만 current production call graph에서 operations.claim(...) 호출은 0개다. NotificationAdminApplicationService의 redrive/reconcile/suppress/provider-state 경로는 여전히 모두:

findByOperationId(operationId)
... action ...
operations.save(...)

를 사용한다.

게다가 현재 persistence completion model은 claim API를 단순히 연결하는 것만으로 끝나지 않는다. claim()notification_admin_auditphase='CLAIMED' row를 먼저 INSERT하지만, JpaAdminOperationStore.save()는 그 row를 update-to-COMPLETED하지 않고 동일 operation_id의 새 entity를 INSERT한다. PostgreSQL probe에서 claim 성공 후 현재 save 방식과 동등한 두 번째 INSERT는 unique violation이 났고 기존 row는 계속 CLAIMED였다.

claim -> INSERT 1, phase=CLAIMED
save-style completion -> unique_violation
final -> phase=CLAIMED

기존 AdminOperationClaimContractTest는 atomic claim primitive 자체는 검증하고 fresh rerun도 green이지만, production service가 이를 쓰는지와 claim→completion lifecycle은 검증하지 않는다.

판정: P2 production idempotency/wiring defect. V8에서 만든 fix가 dead path이며 completion state machine도 이어지지 않는다. DB transaction 안에서 수행되는 redrive/suppress 일부 경로는 마지막 unique conflict가 loser transaction을 rollback시켜 결과를 완화하지만, reconcile/provider runtime control처럼 action과 final audit insert가 하나의 동일 DB transaction으로 묶이지 않는 경로까지 전체적으로 exactly-once operation claim을 보장하지 못한다.

수정은 service entry에서 command fingerprint와 함께 atomic claim을 먼저 수행하고, owner가 아니면 CLAIMED/COMPLETED 상태를 명시적으로 해석해야 한다. winner는 동일 row를 COMPLETED로 update하면서 result snapshot을 저장해야 하며, 별도 duplicate INSERT로 완료해서는 안 된다.

Evidence: evidence/raw/087-notification-admin-claim-completion-probe.txt, 088-notification-admin-claim-reachability.txt, 092-notification-reachability-test-gap.txt.

92. 이번 sub-scope에서 finding으로 올리지 않은 항목

92.1 provider-event replay의 중복 scan 자체

ProviderEventReplayWorker도 unmatched/pending event를 durable lease 없이 scan할 수 있지만, projection write는 transaction 안에서 수행되고 ledger의 applied transition과 suppression side-effect에는 별도 conditional claim이 존재한다. 동일 event가 두 worker에 보일 가능성만으로 중복 external side effect까지 현재 evidence에서 확정할 수 없어 이번 backlog에는 올리지 않았다.

92.2 crypto envelope와 contact-point secret protection

request variable payload는 NotificationPayloadProtection을 필수 collaborator로 받아 보호된 envelope를 저장하고, contact point는 ciphertext/nonce/lookup HMAC/key id로 분리된다. V10은 plaintext-looking request envelope를 DB constraint로도 거부한다. 이번 완독에서 이 경계 자체를 우회하는 production write path는 확인하지 못했다.

92.3 tenant-bound repository guard

tenant-sensitive lookup이 전부 완전하다고 corpus 전체 결론을 내리지는 않았지만, TenantBoundRepositoryGuard와 tenant-qualified repository method가 존재하고 이번 68-file owning scope에서 즉시 재현 가능한 cross-tenant bypass는 확정하지 못했다. 별도 inbound/application authorization 조합은 cross-scope 단계가 소유한다.

93. Fresh Notification verification evidence

  • evidence/raw/083-persistence-jpa-notification-manifest.txt — 53 production Java + 10 migration SQL + 5 dedicated unit Java, 68 / 68 FULL_READ
  • evidence/raw/084-notification-schema-v4-activation-probe.txt — PostgreSQL 16에서 V4 ACTIVE activation은 통과하지만 current-required V5~V9 columns 5개가 모두 absent
  • evidence/raw/085-notification-expired-lease-write-probe.txtstillHeld=0인 expired lease의 owner+fence write가 UPDATE 1
  • evidence/raw/091-notification-stale-provider-overwrite-probe.txt — replacement holder B/fence 8 이후 stale result의 ID-only projection write가 새 lease를 보존한 채 lifecycle을 overwrite
  • evidence/raw/086-notification-reconciliation-claim-probe.txt — autocommit SKIP LOCKED를 worker A/B가 순차 실행해 동일 reconciliation job을 둘 다 획득
  • evidence/raw/087-notification-admin-claim-completion-probe.txt — atomic claim 뒤 current save-style second insert가 unique violation, row는 CLAIMED 유지
  • evidence/raw/088-notification-admin-claim-reachability.txt, 092-notification-reachability-test-gap.txt — production admin claim caller 0, dispatch/reconciliation actual call graph와 predicate snapshot
  • evidence/raw/089-notification-schema-official-readiness.txt — original-source postgresqlNotificationSchemaActivationIntegrationTest --rerun-tasks, BUILD SUCCESSFUL in 40s, 19/19 tasks executed
  • evidence/raw/090-notification-existing-contracts-fresh.txt — original RecipientClaimContractTest + AdminOperationClaimContractTest, fresh jpaPlatformContractTest --rerun-tasks, BUILD SUCCESSFUL in 31s, 19/19 tasks executed

모든 신규 semantic probe는 임시 PostgreSQL container와 repository의 existing migration/source를 읽어 실행했으며 source file을 수정하지 않았다. 최종 code repository git status --short는 clean이다.

94. Sub-scope 08 findings backlog

우선순위 finding reachability
P1 schema activation이 revision 4 ACTIVE를 current-compatible로 인정하지만 code는 V5~V10 schema를 요구 conditional-production when Notification JPA enabled
P1 provider call 결과 recorder가 lease-unaware recipients.save()를 사용해 expired/replaced holder의 stale projection이 새 holder state를 덮을 수 있음 production dispatch
P2 reconciliation FOR UPDATE SKIP LOCKED lock이 SELECT 종료와 함께 풀려 처리 중 동일 job을 다른 worker가 재claim 가능 production multi-instance worker
P2 V8 atomic admin claim API가 production caller 0이고 claim row를 current save()로 완료할 수도 없음 production admin operations

Sub-scope 08은 68 / 68 FULL_READ + targeted real-PostgreSQL verification + original contract/readiness fresh rerun 완료로 닫는다. 다음 owning unit은 sub-scope 09 Experimental platform 38개다.

95. Sub-scope 09 범위와 denominator

Sub-scope 09는 experimental/**가 소유하는 multi-tenancy, RLS, schema/database-per-tenant, read-replica routing, next-version compatibility/promotion surface를 분석한다.

Owning denominator는 38개다.

  • production Java: 29
  • dedicated unit Java: 8
  • experimental RLS migration SQL: 1

여기에 실제 PostgreSQL 의미를 확인하기 위해 postgresqlIntegrationTest/.../platform/experimental의 contract 5개를 verification dependency로 추가 완독했다. 따라서 이번 실행에서 읽은 파일은 38 / 38 owning FULL_READ + 5 / 5 verification dependency FULL_READ = 43개다. 이전 checkpoint의 “38개”와 현재 tree inventory가 처음에는 어긋나 보였지만, 차이는 이 5개 PostgreSQL contract를 owning denominator가 아니라 검증 의존성으로 분리한 데서 나온다.

Evidence: evidence/raw/093-persistence-jpa-experimental-manifest.txt.

96. 현재 production composition은 Experimental을 실행하지 않지만 opt-in 경계는 완전히 구조적이지 않다

현재 repository 내부 production call graph에서는 TenantDataSourceRegistry, TenantEntityManagerFactoryRegistry, SchemaMultiTenantConnectionProvider, ConsistencyAwareDataSourceRouter, RlsTenantSessionBinder, SchemaTenantMigrationOrchestrator 등을 app-bootstrap이나 다른 production leaf가 조립하는 경로를 찾지 못했다. backend.jpa.experimental.* property도 production configuration에서 읽어 bean을 만드는 경로가 없고, 실제 문자열은 ExperimentalFeature enum의 property vocabulary에만 존재한다.

따라서 아래 semantic finding은 현재 app-bootstrap runtime에서 즉시 활성화된 production defect가 아니라 latent experimental defect로 분류한다. 이 구분은 중요하다. public API surface에 올라 있고 같은 artifact에 포함된 library code가 잘못된 것과, 현재 기본 애플리케이션이 그 code를 실제 실행하는 것은 다른 주장이다.

반면 structural opt-in은 완전히 닫혀 있지 않다. PersistenceJpaConfig의 Stable @EntityScan@EnableJpaRepositories 문자열 목록에는 이미 dev.caskeleton.adapter.outbound.persistence.experimental이 들어 있다. 현재 experimental package에는 @Entity, @Repository, JpaRepository, @MappedSuperclass가 없어서 당장 persistence unit에 들어오는 concrete JPA type은 없지만, 이후 experimental entity/repository 하나가 추가되면 별도 feature condition 없이 Stable persistence unit이 스캔한다.

Evidence: evidence/raw/096-experimental-gate-reachability.txt, 099-experimental-structural-optin-gap.txt.

97. P1 latent — RLS verifier가 “반드시 보호돼야 하는 table”의 부재를 성공으로 인정한다

RlsPolicyVerifier.requireEnforced(runtimeDataSource, tenantScopedTables)의 이름과 Javadoc은 caller가 지정한 tenant-scoped table들이 실제로 RLS에 의해 보호되는지 증명하는 contract다. 구현은 runtime role의 BYPASSRLS를 확인하고, current_schema()의 실제 table들을 순회하면서 이름이 tenantScopedTables에 포함된 row만 검사한다.

문제는 반대 방향 검증이 없다는 것이다. 즉 caller가 요구한 table 이름이 실제 catalog 결과에 한 번도 등장하지 않아도 성공한다.

requested = [missing_tenant_scoped_table]
actual catalog row = rls_item

loop:
  rls_item ∉ requested -> continue
loop end -> success

PostgreSQL 16에서 존재하지 않는 required table 하나를 넘긴 probe도 exception 없이 종료됐다.

experimentalRls.requiredTable=missing_tenant_scoped_table
experimentalRls.verifierAcceptedMissingTable=true
BUILD SUCCESSFUL

이 경계가 위험한 이유는 단순히 “없는 table을 못 찾는다”가 아니다. tenant table rename/config drift/오타로 expected list가 stale해지면 verifier는 실제 tenant table을 검사하지 않은 채 startup evidence를 성공으로 만들 수 있다. security verifier가 coverage 대상 자체를 증명하지 못하는 fail-open이다.

판정: P1 latent security verification defect. 현재 기본 composition에는 RLS capability가 연결되지 않아 latent지만, 기능을 활성화해 이 verifier를 startup guard로 사용하는 순간 잘못된 table inventory가 green으로 통과한다.

수정은 catalog에서 발견한 tenant-scoped 대상의 상태만 검사할 것이 아니라 requested - discovered가 비어 있음을 먼저 강제해야 한다. 가능하면 expected table inventory도 임의 문자열 list가 아니라 migration/schema registry의 SSOT에서 파생하고, missing/renamed table을 real-PostgreSQL regression으로 고정해야 한다.

Evidence: evidence/raw/098-experimental-rls-missing-table-probe.txt.

98. P1 latent — database-per-tenant global connection budget이 새 pool 크기를 계산하지 않아 ceiling을 넘긴다

TenantPoolBudget 문서는 pool 개수와 전체 connection 합계를 모두 제한해야 한다고 명시한다. 특히 pool마다 크기가 다르기 때문에 connection total ceiling이 별도로 필요하다고 설명한다.

하지만 TenantDataSourceRegistry.require()의 순서는 다음이다.

1. 현재 openPools / allocatedConnections 계산
2. budget.requireCapacity(currentOpenPools, currentAllocatedConnections)
3. 새 DataSource 생성
4. map에 추가

requireCapacity() 역시 현재 값이 이미 ceiling 이상인지 확인할 뿐, 이번에 추가할 pool의 크기를 인자로 받지 않는다.

따라서 maxConnectionsAcrossPools=10이고 현재 8 connections을 가진 pool 하나가 열려 있으면 8 < 10이므로 admission이 통과한다. 그 다음 5-connection pool을 열면 결과는 13이다.

실측 probe:

experimentalPool.maxConnections=10
experimentalPool.openPools=2
experimentalPool.allocatedConnections=13
BUILD SUCCESSFUL

기존 TenantPoolCapacityContractTest는 모든 tenant pool 크기를 2로 고정하고 4/8, 2/4처럼 정확히 boundary에 도달한 뒤 다음 tenant를 거부하는 case만 검증한다. 그래서 remaining capacity보다 다음 pool이 더 큰 case를 보지 못한다.

판정: P1 latent fleet-capacity defect. 이 기능의 자체 문서가 connection ceiling 초과 시 한 tenant만이 아니라 전체 DB fleet이 connection refusal을 맞을 수 있다고 정의한다. 현재 app runtime에는 database-per-tenant registry가 조립되지 않아 latent지만, library contract 자체는 global ceiling을 보장하지 못한다.

수정은 admission이 current + candidate를 검사하게 해야 한다. 후보 pool size를 creation 전에 알 수 있는 profile metadata를 budget input으로 넣거나, 불가피하게 pool을 먼저 만들면 map에 publish하기 전에 size를 검증하고 초과 시 즉시 close해야 한다. regression은 heterogeneous pool sizes로 8 + 5 > 10 같은 부분 여유 case를 포함해야 한다.

Evidence: evidence/raw/095-experimental-pool-overshoot-probe.txt.

99. P2 latent — replica evidence가 완전히 unavailable이어도 EVENTUAL read는 replica로 간다

ReplicaLagMonitor의 contract는 명확하다.

  • monitor down
  • replica unreachable
  • lag metric stale

같이 freshness evidence를 얻을 수 없으면 “I do not know”이고 router는 primary를 사용해야 한다. satisfies() Javadoc도 evidence가 없으면 false가 default라고 적는다.

그러나 구현은 consistency level별로 다음처럼 분기한다.

EVENTUAL           -> true
BOUNDED_STALENESS  -> lag().map(...).orElse(false)
PRIMARY_REQUIRED   -> replayedThrough().map(...).orElse(false)

즉 EVENTUAL만 evidence availability를 전혀 보지 않는다. lag()replayedThrough()가 모두 Optional.empty()인 monitor를 넣은 probe는 replica를 선택했다.

experimentalReplica.unavailableEvidence=true
experimentalReplica.consistency=EVENTUAL
experimentalReplica.target=REPLICA

EVENTUAL이 stale data를 허용하는 것과 replica가 usable하다는 evidence 자체가 없는 것은 다른 조건이다. 현재 contract는 후자를 primary fallback 조건으로 선언해 놓고 EVENTUAL path에서만 우회한다.

판정: P2 latent routing fail-open. 현재 default runtime에는 router가 조립되지 않는다. 활성화될 경우 monitor outage/unknown state에서 eventual read가 replica target을 선택할 수 있다.

수정은 consistency satisfaction과 replica health/evidence availability를 분리하는 편이 명확하다. EVENTUAL은 staleness bound를 요구하지 않을 수 있지만, 최소한 replica가 현재 route 가능한 대상이라는 health/evidence gate는 공통으로 통과해야 한다.

Evidence: evidence/raw/097-experimental-replica-provider-probe.txt.

100. P2 latent — Hibernate compatibility policy가 8만 blacklist하고 unknown major 9를 Stable 교체 가능으로 인정한다

HibernateCompatibilityPolicy.mayReplaceStableProvider(version)은 이름 그대로 특정 provider version이 promotion 없이 Stable provider를 대체해도 되는지를 답한다.

현재 구현은 !providerPolicy.isExperimental(version)이다. 그런데 HibernateProviderPolicy의 experimental provider list는 List.of("8") 하나뿐이다. 결과적으로 known Stable 7.x는 true, known Experimental 8.x는 false지만 아직 어떤 compatibility evidence도 없는 9.x 같은 unknown major는 true가 된다.

probe:

experimentalHibernate.candidate=9.0.0.Final
experimentalHibernate.mayReplaceStable=true

이는 compatibility policy를 denylist로 모델링한 결과다. provider generation이 추가될수록 미측정 버전이 자동 허용되는 방향이라 promotion gate의 목적과 반대다.

기존 test도 7.x true와 8.x false만 검증해 unknown-major 경계를 놓친다.

판정: P2 latent compatibility fail-open. 실제 classpath는 현재 Hibernate 7이고 Hibernate 8 workflow도 NOT_EXECUTABLE을 명시하므로 지금 Stable runtime이 9.x라는 주장은 아니다. 문제는 policy가 미래 unknown major를 자동 승인한다는 점이다.

수정은 “experimental이 아니면 Stable”이 아니라 명시적으로 허용된 Stable generation만 true가 되게 해야 한다. 현재 policy 의도대로라면 최소 7.x allowlist 외 major는 false로 닫고, 새 major는 compatibility lane + promotion evidence를 거쳐 allowlist를 바꾸는 방향이 맞다.

Evidence: evidence/raw/097-experimental-replica-provider-probe.txt.

101. P2 latent — experimental opt-in이 세 entry point에만 강제되고 Stable scan은 experimental package를 이미 포함한다

experimental plan의 global constraint는 “모든 기능은 backend.jpa.experimental.* feature flag를 요구한다”이다. ExperimentalEntryConsentTest도 더 강하게 “behaviour-bearing entry point는 외부 package에서 public constructor로 만들 수 없어야 하고 gate-taking enabledBy factory만 제공해야 한다”고 선언한다.

그 test가 실제로 열거하는 class는 세 개뿐이다.

ConsistencyAwareDataSourceRouter
RlsTenantSessionBinder
SchemaTenantMigrationOrchestrator

하지만 같은 experimental public API에는 flag 없이 바로 생성해서 behavior를 실행할 수 있는 type이 더 있다.

  • TenantDataSourceRegistry — public constructor + tenant pool open
  • TenantEntityManagerFactoryRegistry — public constructor + tenant EMF build
  • SchemaMultiTenantConnectionProvider — public constructor + connection search_path 변경
  • TenantEntityListenerGuard — public constructor + @PrePersist/@PreUpdate tenant write guard

javap -public로 이 constructor surface를 확인했고, 현재 repository production caller는 0이었다. 즉 지금 app-bootstrap이 우회하고 있다는 finding이 아니라 consent test가 “all entry points”라고 부르는 집합 자체가 수동 3-class allowlist라 새/기존 activator를 놓친다는 finding이다.

여기에 Stable PersistenceJpaConfig가 experimental package를 @EntityScan/@EnableJpaRepositories에 unconditional string으로 포함하는 구조가 겹친다. 현재 JPA stereotype이 0개라 즉시 bean activation은 없지만, 향후 experimental entity/repository가 추가되면 이 경로는 feature gate를 거치지 않는다. ArchUnit의 Stable→Experimental dependency rule은 bytecode type edge를 검사하므로 문자열 package scan을 잡지 못한다.

판정: P2 latent architecture/consent gap. 현재 production wiring이 없어 latent지만 “presence on classpath is not consent”라는 핵심 방어가 type마다 일관되게 강제되지 않는다.

수정은 experimental capability를 Stable persistence scan에서 제외하고, 각 feature가 자기 gated configuration에서 필요한 entity/repository/bean을 조립하게 해야 한다. entry-point 검증도 수동 3-class list가 아니라 annotation/package convention 또는 explicit registry SSOT에서 exhaustive하게 파생해야 새 behavior-bearing type이 추가될 때 test가 fail-closed 해야 한다.

Evidence: evidence/raw/096-experimental-gate-reachability.txt, 099-experimental-structural-optin-gap.txt.

102. 이번 sub-scope에서 finding으로 올리지 않은 항목

102.1 JPA 4 / Hibernate 8 / PostgreSQL 19 workflow의 NOT_EXECUTABLE

세 workflow는 현재 target dependency/server를 실제로 resolve/run하지 않는다. 그러나 artifact에 status=NOT_EXECUTABLE과 이유를 명시하고 promotion checklist도 missing evidence를 통과로 취급하지 않는다. 따라서 “실행하지 않았는데 compatibility green으로 속인다”는 false-evidence finding으로 올리지 않는다. 실제 lane이 실행 가능해지기 전까지는 미검증 상태다.

102.2 RLS tenant binding 자체

RlsTenantSessionBinderset_config(..., true)로 transaction-local tenant setting을 사용하고, existing real-PostgreSQL contract는 session-scoped setting이 pool reuse에서 leak하는 case와 transaction-scoped setting이 leak하지 않는 case를 구분한다. 이번 분석에서 binder 자체의 cross-tenant leak을 재현하지 못했다.

102.3 schema identifier selection/reset

SchemaTenantRegistry는 unquoted PostgreSQL identifier shape를 제한하고, connection provider는 schema 값을 statement text에 직접 붙이지 않고 bound set_config로 적용하며 release 시 neutral pg_catalog로 reset한다. 별도 failure-in-reset / pool-implementation semantics까지 corpus 전체 보장은 하지 않지만, 현재 happy-path isolation contract를 뒤집을 evidence는 없었다.

102.4 tenant repository/listener guard가 곧 production isolation이라는 주장

TenantAwareRepositoryGuardTenantEntityListenerGuard의 local behavior는 fail-closed지만 현재 production repository/entity에 연결된 caller/listener registration은 없다. 따라서 이 type들이 존재한다는 이유만으로 현재 application의 tenant isolation이 보장된다고 쓰지 않는다.

103. Fresh Experimental verification evidence

  • evidence/raw/093-persistence-jpa-experimental-manifest.txt38 / 38 owning FULL_READ(29 production + 8 unit + 1 SQL), 추가 PostgreSQL verification dependency 5 / 5 FULL_READ
  • evidence/raw/095-experimental-pool-overshoot-probe.txt — connection ceiling 10에서 heterogeneous pools 8 + 5가 admission되어 total 13으로 overshoot, BUILD SUCCESSFUL
  • evidence/raw/097-experimental-replica-provider-probe.txt — evidence가 모두 empty인 EVENTUAL read가 REPLICA, unknown Hibernate 9.0.0.FinalmayReplaceStable=true, BUILD SUCCESSFUL
  • evidence/raw/098-experimental-rls-missing-table-probe.txt — PostgreSQL 16에서 required missing table을 RlsPolicyVerifier가 성공으로 인정, jpaPlatformSecurityTest BUILD SUCCESSFUL
  • evidence/raw/096-experimental-gate-reachability.txt, 099-experimental-structural-optin-gap.txt — omitted public activation constructors, production caller 0, Stable scan의 experimental package unconditional inclusion
  • evidence/raw/100-experimental-original-unit-tests.txt — original-source experimental 8 unit classes + JpaModuleBoundaryTest + PersistenceEntityScanCoverageTest, fresh --rerun-tasks, BUILD SUCCESSFUL in 28s / 18 actionable executed, git clean before/after
  • evidence/raw/101-experimental-original-postgresql-contracts.txt — original-source contract 3 classes BUILD SUCCESSFUL in 30s / 19 executed, schema migration BUILD SUCCESSFUL in 5s, RLS security BUILD SUCCESSFUL in 4s, git clean before/after
  • evidence/raw/094-preflight-fixture-compile-failure.txt — 최초 analysis-only DataSource fixture의 @Override 누락이 repository -Werror에 걸린 preflight 실패. 제품 code failure가 아니며 fixture 수정 후 095/097 clean probe로 대체했다.

모든 semantic probe는 temporary test source를 trap으로 복원했다. 최종 code repository git status --short는 clean이다.

104. Sub-scope 09 findings backlog

우선순위 finding reachability
P1 latent RLS verifier가 requested tenant table의 존재/coverage를 확인하지 않아 missing/stale table inventory를 green으로 인정 experimental public API, current app runtime unwired
P1 latent database-per-tenant admission이 candidate pool size를 반영하지 않아 global connection ceiling을 초과 가능 experimental public API, current app runtime unwired
P2 latent replica evidence가 unavailable이어도 EVENTUAL read는 replica target을 선택 experimental public API, current app runtime unwired
P2 latent Hibernate compatibility policy가 8.x만 deny해 unknown 9.x를 promotion 없이 Stable 교체 가능으로 판정 experimental compatibility policy, current Stable classpath 7.x
P2 latent feature consent가 3개 entry point에만 구조적으로 강제되고 Stable JPA scan은 experimental package를 unconditional 포함 latent structural activation path; current experimental JPA stereotypes 0

Sub-scope 09는 38 / 38 owning FULL_READ + 5 / 5 PostgreSQL verification dependency FULL_READ + targeted semantic probes + original unit/contract/migration/security fresh rerun으로 닫는다.

105. Sub-scope 10 범위와 denominator

Sub-scope 10은 JPA platform의 testkit 자체와 그 testkit을 검증하는 fixture/unit test를 소유한다. 이 범위는 production persistence 동작이 아니라 그 동작을 증명한다고 주장하는 architecture rule, query/plan assertion, failure injector, migration runner, release registry adapter, PostgreSQL matrix helper가 false-green evidence를 만들 수 있는지를 본다.

Owning denominator는 정확히 62개다.

  • src/testkit/java/**: 41 Java
  • src/test/java/**/testkit/**: 21 Java

이번 sub-scope에서 62 / 62 FULL_READ했다.

Evidence: evidence/raw/102-persistence-jpa-testkit-manifest.txt.

106. Testkit reachability를 production guard와 self-test helper로 나눈다

같은 testkit package에 있어도 영향도는 동일하지 않다.

production/release evidence에 실제 연결된 핵심 helper는 다음이다.

  • JpaArchitectureRules / EntityExposureConditionapp-bootstrapJpaProductionArchitectureTest가 실제 production graph에 적용한다.
  • JpaAuditMechanismRule — 동일 production architecture suite가 audit mechanism과 audited bulk update를 검사한다.
  • PostgreSqlExplainRunner / QueryPlanAssertions / QueryPlanExpectation — real PostgreSQL PostgreSqlQueryPlanContractTest가 사용하고 jpaPlatformQueryPlanTest라는 blocking release Test task 안에서 실행된다.
  • MigrationContractRunner, CountingDataSource, lifecycle/mapping fixture들은 실제 PostgreSQL integration lane에서 사용된다.

반대로 public surface지만 현재 repository source에서 defining file 외 reference가 0인 helper도 있다.

  • CommitAmbiguityProxy
  • PostgreSqlContractExtension

testkit.id.UuidV7Generator도 simple name은 notification 모듈의 별도 production UuidV7Generator와 충돌하지만 정확한 testkit FQN consumer는 0이다. 현재는 자신의 unit test만 존재한다.

이 구분 때문에 아래 backlog는 “testkit code에 버그가 있다”만으로 승격하지 않고, 현재 production architecture/release evidence producer와 연결된 false-negative를 우선한다.

Evidence: evidence/raw/105-testkit-public-reachability-sweep.txt.

107. P1 latent — SELECT-only query-plan runner가 data-modifying CTE를 허용해 EXPLAIN ANALYZE가 실제 DML을 실행한다

PostgreSqlExplainRunner는 이 위험을 정확히 문서화한다.

EXPLAIN ANALYZE executes the statement;
therefore this runner refuses anything but SELECT.

하지만 실제 requireReadOnly()는 다음 두 prefix를 허용한다.

select...
with...

PostgreSQL의 WITH는 read-only CTE만 의미하지 않는다. data-modifying CTE가 가능하다.

WITH changed AS (
  UPDATE plan_row
  SET bucket = 99
  WHERE id = ?
  RETURNING id
)
SELECT id FROM changed

이 statement는 WITH로 시작하므로 guard를 통과하고, runner가 붙이는 EXPLAIN (ANALYZE, ...)는 실제 UPDATE를 실행한다.

PostgreSQL 16 real-container probe에서 id 42의 bucket을 조회한 뒤 위 statement를 runner에 넣었다.

testkitExplain.guardAcceptedWithUpdateCte=true
testkitExplain.bucketBefore=9
testkitExplain.bucketAfter=99
BUILD SUCCESSFUL

즉 guard가 보호한다고 명시한 side-effect가 실제로 발생했다.

기존 refusesNonSelect() contract는 direct UPDATE ...만 넣기 때문에 green이다. 원본 PostgreSqlQueryPlanContractTest 전체도 fresh rerun에서 green이지만, 그것은 현재 input이 SELECT라는 증거이지 WITH가 read-only라는 증거가 아니다.

판정: P1 latent release-evidence safety defect. 현재 committed query-plan tests는 SELECT만 사용하므로 지금 release run이 DB를 변경했다는 주장은 아니다. 그러나 이 helper는 blocking jpaPlatformQueryPlanTest가 사용하는 evidence producer이고, documented safety invariant를 우회하는 legal PostgreSQL syntax가 실제 mutation까지 재현됐다.

수정은 string prefix whitelist로 SQL read-only 여부를 판정하지 않는 방향이 필요하다. 최소한 data-modifying CTE를 fail-closed로 거부하는 parser/statement classification을 사용하고, query-plan lane 자체의 connection/transaction도 read-only defense-in-depth로 묶어야 한다. regression은 direct UPDATE뿐 아니라 WITH ... UPDATE/DELETE/INSERT ... SELECT를 포함해야 한다.

Evidence: evidence/raw/104-testkit-explain-dml-cte-probe.txt, 106-testkit-original-verification.txt.

108. P1 latent — production entity-exposure rule이 async/reactive wrapper 안의 JPA entity를 보지 못한다

EntityExposureCondition의 목적은 controller/web method가 persistence entity를 return graph 어디에서도 노출하지 못하게 하는 것이다. 직접 entity뿐 아니라 List<Entity>, Optional<Entity>, Map<..., Entity>까지 generic argument를 검사한다고 명시한다.

하지만 generic traversal은 raw return type이 다음 container일 때만 실행된다.

Collection
Map
Optional
array

따라서 다음처럼 실제 transport 계층에서 흔한 wrapper는 raw type 단계에서 즉시 Optional.empty()가 된다.

CompletableFuture<OrderEntity>
CompletionStage<OrderEntity>
ResponseEntity<OrderEntity>
Mono<OrderEntity>
Flux<OrderEntity>
Page<OrderEntity>

analysis fixture로 CompletableFuture<OrderEntity>를 반환하는 ..web.. class를 넣고 **실제 production에서 사용하는 동일 JpaArchitectureRules.noEntityFromWeb()**를 실행했다. rule은 exception 없이 통과했다.

testkitArchitecture.wrapper=CompletableFuture<OrderEntity>
testkitArchitecture.entityLeakAccepted=true

이 문제는 가상의 wrapper family만의 이야기가 아니다. 현재 inbound web 코드도 Mono<ResponseEntity<...>> 같은 nested transport wrapper를 실제로 사용한다. 지금 그 내부 payload는 persistence entity가 아니라 DTO/String이므로 current production violation은 확인되지 않았고, 원본 JpaProductionArchitectureTest도 fresh green이다.

판정: P1 latent architecture-enforcement false-negative. controller가 persistence entity를 직접 노출하는 것은 repository의 HARD-STOP 계열 경계이고, 이 rule은 release-wide app-bootstrap:test에서 그 경계를 증명하는 production guard다. 현재 code가 위반 중이라는 finding이 아니라, 위반을 추가해도 대표적인 async wrapper 형태면 guard가 green일 수 있다는 것이 finding이다.

수정은 container allowlist로 들어갈지 말지를 결정하지 말고 return JavaType의 generic graph를 재귀적으로 traverse하되 cycle을 방지하는 방식이 더 안전하다. 최소 regression에는 CompletableFuture<Entity>와 실제 runtime stack의 Mono<ResponseEntity<Entity>> 또는 동등한 nested wrapper를 포함해야 한다.

Evidence: evidence/raw/103-testkit-unit-boundary-probes.txt, 106-testkit-original-verification.txt.

109. P2 latent — plan normalizer가 root node 하나의 estimate ratio만 읽어 child node의 큰 cardinality miss를 숨긴다

PostgreSqlExplainRunner.normalize()Node Type은 전체 JSON에서 반복 탐색한다. 반면 Actual Rows, Plan Rows, Shared Read BlocksindexOf(key)첫 occurrence 하나만 읽는다.

PostgreSQL JSON plan은 root node 뒤에 child Plans[]가 중첩되는 구조이므로 현재 estimateRatio는 사실상 root node ratio다.

analysis probe에 다음 plan을 넣었다.

root Nested Loop: Actual 10 / Plan 10       -> ratio 1
child Seq Scan:   Actual 1000 / Plan 1      -> ratio 1000

normalizer는 child node type은 발견하면서 estimate ratio는 1.0으로 보고했다.

testkitPlan.nodes=[Nested Loop, Seq Scan]
testkitPlan.reportedEstimateRatio=1.0
testkitPlan.childActualToPlanned=1000.0

QueryPlanExpectation.estimateOnly(10) 같은 assertion은 이런 plan을 estimate-quality 관점에서 green으로 통과시킬 수 있다.

판정: P2 latent query-plan false-evidence. 현재 committed estimate assertion의 대표 query는 단순 index lookup이라 이 probe만으로 현재 release 결과가 거짓이라고 확대하지 않는다. 그러나 blocking query-plan lane의 normalized model이 “planner estimate error”를 plan 전체가 아니라 root 한 node로 축소하는 것은 명시적인 측정 공백이다.

수정은 node별 actual/planned pair를 구조적으로 parse하고, maximum symmetric error ratio 또는 명시한 aggregation policy를 NormalizedPlan에 보존해야 한다. raw JSON을 substring scanning하기보다 JSON tree parser로 node recursion을 수행하는 것이 node pairing과 buffer aggregation 모두 안전하다.

Evidence: evidence/raw/103-testkit-unit-boundary-probes.txt.

110. P2 latent — audited bulk-update guard가 audit column 이름을 “대입 대상”이 아니라 substring으로 찾아 false-green을 만든다

JpaAuditMechanismRule.bulkUpdateViolation()은 audited entity의 bulk update가 updated_at/updatedAt/modified_at/modifiedAt를 직접 stamp하는지 검사한다.

하지만 현재 판정은 SET ... WHERE 문자열 전체에 audit-column token이 어디든 포함되는지만 본다.

AUDIT_COLUMNS.stream().anyMatch(assignments::contains)

따라서 실제 audit field를 변경하지 않고 parameter 이름에 token만 들어 있어도 통과한다.

update WorkLogEntity w
set w.status = :updatedAtValue
where w.id = :id

probe 결과:

testkitAudit.decoyParameter=:updatedAtValue
testkitAudit.violationPresent=false

w.updatedAt = ... assignment가 하나도 없는데 “stamp 있음”으로 해석했다.

현재 production graph에는 이 rule의 audited-entity bulk-update branch를 실제로 밟는 committed query가 없어 original architecture suite는 green이다. class 자체도 이 점을 Javadoc에서 인정하고 direct branch unit test를 둔다. 문제는 그 branch test가 real assignment와 완전 unstamped case만 보고 decoy token을 보지 않는다는 것이다.

판정: P2 latent audit architecture false-negative. 현재 unstamped audited bulk update가 production에 있다는 주장은 아니다. 다만 future query가 parameter/함수/문자열 literal에 audit token을 포함하면 release architecture guard가 잘못 통과할 수 있다.

수정은 SQL/JPQL의 SET assignment left-hand side를 최소한 token boundary 기준으로 추출하여 audit property/column과 정확히 비교해야 한다. regression에는 parameter name, string literal, unrelated property suffix에 updatedAt token이 포함된 case를 넣어야 한다.

Evidence: evidence/raw/103-testkit-unit-boundary-probes.txt, 106-testkit-original-verification.txt.

111. 이번 sub-scope에서 finding으로 올리지 않은 항목

111.1 UuidV7Generator same-millisecond wrap

이 testkit generator는 12-bit counter를 같은 millisecond마다 & 0x0FFF로 증가시킨다. lower-half random seed는 wrap을 제거하지 않고 늦출 뿐이다. deterministic probe에서는 같은 millisecond 3,784번째에 이전 UUID보다 작아지는 정렬 역전이 재현됐다.

testkitUuid.sameMillisecondGeneratedBeforeInversion=3784
testkitUuid.monotonicityBroken=true

하지만 exact dev.caskeleton.adapter.outbound.persistence.testkit.id.UuidV7Generator FQN의 repository consumer는 현재 0이고, notification runtime이 사용하는 production UuidV7Generator는 다른 모듈의 별도 class다. 따라서 production UUID defect로 승격하지 않고 self-tested unadopted test fixture defect로 남긴다. 만약 이 fixture를 ID-strategy PostgreSQL contract에 실제 채택한다면 그 시점에는 same-ms exhaustion policy가 필요하다.

111.2 EntityState.REMOVED

EntityState enum은 REMOVED를 제공하지만 EntityStateProbe.stateOf() 구현은 MANAGED, TRANSIENT, DETACHED 세 값만 반환한다. REMOVED consumer/assertion도 현재 없다. API vocabulary와 probe capability가 어긋나지만 current evidence lane을 잘못 통과시키는 소비 경로가 없어 backlog 우선순위에는 올리지 않는다.

111.3 CommitAmbiguityProxy / PostgreSqlContractExtension

두 public helper는 defining file 밖 exact FQN reference가 0이다. 특히 PostgreSqlContractExtension.serverVersion()은 이름과 달리 database의 SHOW server_version이 아니라 Docker image + container id를 반환하지만 현재 integration support는 별도 JpaPlatformContractSupport.serverVersion()로 실제 server version을 읽는다. 따라서 잘못된 current evidence로 분류하지 않고 dead/unadopted helper로 기록한다.

111.4 JpaReleaseManifest의 regex parser

Java testkit parser 자체는 정규식 기반이라 일반-purpose JSON parser가 아니다. 그러나 실제 registry 파일은 root Gradle verifyJpaReleaseGateTasks에서 JsonSlurper로 다시 parse되고 real task graph까지 resolve한다. 현재 malformed JSON을 Java regex parser 하나가 받아들일 가능성만으로 release fail-open을 별도 finding으로 중복 승격하지 않는다.

112. Fresh Testkit verification evidence

  • evidence/raw/102-persistence-jpa-testkit-manifest.txt62 / 62 FULL_READ (41 testkit Java + 21 fixture/unit Java)
  • evidence/raw/103-testkit-unit-boundary-probes.txt — async wrapper entity leak accepted, audit decoy token accepted, child estimate miss hidden by root ratio, testkit UUID same-ms inversion 재현; analysis fixture는 trap으로 복원
  • evidence/raw/104-testkit-explain-dml-cte-probe.txt — PostgreSQL 16에서 WITH UPDATE ... SELECT가 read-only guard를 통과하고 EXPLAIN ANALYZE로 row를 실제 9 -> 99 변경; BUILD SUCCESSFUL
  • evidence/raw/105-testkit-public-reachability-sweep.txt — public testkit symbol reachability와 zero-reference helper sweep
  • evidence/raw/106-testkit-original-verification.txt — 원본 source 상태에서 :adapter:outbound:persistence-jpa:test --rerun-tasks BUILD SUCCESSFUL in 29s / 18 executed, JpaProductionArchitectureTest BUILD SUCCESSFUL in 1m47s / 98 executed, original PostgreSqlQueryPlanContractTest BUILD SUCCESSFUL in 25s / 19 executed, git clean before/after

모든 analysis-only source/fixture 변경은 trap으로 복원했고 최종 code repository git status --short는 clean이다.

113. Sub-scope 10 findings backlog

우선순위 finding reachability
P1 latent PostgreSqlExplainRunner가 모든 WITH를 read-only로 허용해 data-modifying CTE를 EXPLAIN ANALYZE로 실제 실행 가능 blocking query-plan evidence producer; current committed inputs are SELECT
P1 latent production noEntityFromWeb architecture rule이 async/reactive/custom wrapper 안 entity를 탐색하지 않아 CompletableFuture<Entity>가 green release-wide app-bootstrap architecture guard; current payloads are DTOs
P2 latent query-plan estimate ratio가 root node first pair만 사용해 child cardinality miss를 숨김 blocking query-plan evidence producer
P2 latent audited bulk-update guard가 audit column substring만 찾아 parameter/string decoy로 false-green production architecture guard; current audited bulk-update branch absent

Sub-scope 10은 62 / 62 FULL_READ + four focused unit false-negative probes + real-PostgreSQL DML-CTE probe + full original unit/production-architecture/query-plan fresh rerun으로 닫는다.


114. Sub-scope 01 범위와 denominator

내부 상태: COMPLETE — 11 / 11 FULL_READ 범위: leaf 최상위 4개 파일 + package-info.java + config/** production 3개 + root-level test 3개 역할: 이 leaf가 "무엇이고, 무엇을 always-install하며, 어떤 경계를 스스로 강제하는가"를 선언하는 층

구분 파일 라인
governance CLAUDE.md 295
rationale README.md 379
build build.gradle 356
build gradle.lockfile 224
production package-info.java 2
production config/JpaAdapterComponentsConfig.java 68
production config/PersistenceJpaConfig.java 95
production config/PersistenceVendorSettings.java 35
test CandidateAdapterCompositionTest.java 63
test JpaModuleBoundaryTest.java 411
test integration/.gitkeep 1

denominator 근거는 evidence/raw/107-persistence-jpa-governance-manifest.txt다. 이 11개는 "다른 어떤 sub-scope manifest도 claim하지 않은 leaf tracked file"로 정의했다. 특히 config/PersistenceEntityScanCoverageTest, config/PersistenceVendorSelectionTest, platform/PoolLaneClaimTest 3개 test는 sub-scope 06 manifest(070-...)가 이미 자기 denominator에 넣었기 때문에 여기서 다시 세지 않는다. 대신 이 sub-scope는 그 test들이 검사하는 대상인 production type을 소유한다. 즉 소유 경계는 "production type은 01, 그 test의 계수는 06"으로 갈라져 있고, 이 문서는 그 사실을 명시한 뒤 내용 분석은 여기서 한다.

.gitkeep은 빈 디렉터리 marker이므로 STRUCTURAL_ONLY가 아니라 FULL_READ(1바이트, 내용 없음)로 처리했다. gradle.lockfile은 224줄 전부를 읽었으나 해석은 configuration별 classpath 소속 확인에 한정했다.

115. governance는 세 겹이고, 세 겹의 강제력이 서로 다르다

이 leaf의 규칙은 세 곳에 나뉘어 있고 각각 강제 수단이 다르다.

문서/코드 강제 수단 위반 시 실패 지점
정책 서술 CLAUDE.md 없음(산문) 없음 — 읽는 사람만 안다
근거 서술 README.md 없음(산문) 없음
build 계약 build.gradle Gradle task task 실행 시
package 경계 JpaModuleBoundaryTest ArchUnit + 파일시스템 대조 :test 실행 시

build.gradle은 산문이 아니라 실제 계약이다. strictTestLanespostgresqlIntegrationTest / testkit / jpaPlatformPerformanceTest 세 source set을 만들고, testkitPublisher가 testkit을 testpostgresqlIntegrationTest에만 소비시킨다. 즉 "production module은 testkit에 의존하지 않는다"는 설계 주장은 여기서 구조적으로 참이 되고, JpaModuleBoundaryTest.noProductionClassDependsOnTheTestkit()가 bytecode 수준에서 다시 확인한다. 같은 주장을 두 층이 서로 다른 방식으로 잡는다.

JpaModuleBoundaryTest는 이 leaf에서 가장 강한 governance 장치다. 24개 top-level package 각각의 허용 edge를 PACKAGE_CATALOG에 닫힌 집합으로 적고, 디스크의 실제 package 목록과 정확히 같은 집합인지 양방향으로 대조하며(theCatalogNamesExactlyThePackagesThatExist), 관측된 모든 package 간 edge가 선언된 edge인지 확인하고(everyObservedEdgeIsDeclared), 선언된 edge가 DAG인지 검사한다(theDeclaredEdgesFormADag). importActuallyLoadedTheProductionClassesnoClasses() rule이 "아무것도 매칭되지 않아 vacuously 통과"하는 실패 모드를 명시적으로 막는다 — ArchUnit rule 모음에서 가장 자주 조용히 무너지는 지점을 이 파일은 알고 있다.

다만 catalog의 쪽은 key 쪽만큼 검증되지 않는다. postgresql의 허용 대상에 "inbox"가 들어 있는데 ..persistence.inbox라는 top-level package는 존재하지 않는다(실제 inbox는 postgresql.inbox 하위 package라 topLevelPackageOf()가 항상 postgresql을 돌려준다). 즉 이 항목은 어떤 edge도 허용하지 않는 사문(死文)이다. theCatalogNamesExactlyThePackagesThatExist는 key만 대조하므로 이런 값은 잡히지 않는다. 방향은 안전한 쪽이다 — 존재하지 않는 이름은 rule을 더 엄격하게 만들 뿐 느슨하게 만들지 않는다 — 그래서 결함이 아니라 잔여 설정으로 기록한다. 근거: evidence/raw/110-governance-doc-count-drift.txt §I.

116. Confirmed P2 — vendor selector의 fail-fast 계약이 shipped composition에 설치돼 있지 않다

PersistenceVendorSettings는 자기 존재 이유를 javadoc에 명확히 적는다.

Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the two @ConditionalOnProperty vendor configurations would both stay off, and the first missing SPI bean would surface as a NoSuchBeanDefinitionException naming OutboxClaimRepository — a symptom several layers away from the misspelled value that caused it.

CLAUDE.md §Vendor selection도 같은 주장을 한다. application.yml:463 주석은 한 발 더 나가 "PersistenceVendorSettings (adapter-persistence), which rejects any other value at startup"라고 쓴다.

문제는 이 타입이 production에서 한 번도 @ConfigurationProperties bean으로 등록되지 않는다는 것이다.

  • leaf 안의 @ConfigurationProperties 타입은 정확히 3개다: LockSettings, JpaTransactionSettings, PersistenceVendorSettings.
  • 그중 앞의 둘만 같은 package 안의 configuration이 @EnableConfigurationProperties로 켠다 — DistributedLockPersistenceConfig, JpaTransactionConfig.
  • PersistenceVendorSettings를 켜는 곳은 repository 전체에서 PersistenceVendorSelectionTest의 중첩 @Configuration 하나뿐이다.
  • composition root CaSkeletonApplication@ConfigurationPropertiesScan basePackages 21개 중 dev.caskeleton.adapter.outbound.persistence를 덮는 항목은 0개다.
  • PersistenceJpaRootAutoConfiguration은 이 사실을 알고 있고 javadoc에 적어 두었다 — vendor를 Environment.getProperty(...)로 직접 읽는다.

역설적인 것은 JpaAdapterComponentsConfig의 javadoc이 규칙 자체를 정확히 서술한다는 점이다. "Each package's @ConfigurationProperties type is enabled by a configuration inside that same package — JpaTransactionConfig for JpaTransactionSettings, DistributedLockPersistenceConfig for LockSettings". 셋 중 둘을 열거하고 셋째를 빠뜨렸는데, 그 셋째를 소유한 package가 바로 이 javadoc이 들어 있는 config다.

실행 probe

evidence/raw/109-vendor-selector-shipped-shape-probe.txt / 109a-...java. shipped composition과 같은 모양(두 vendor configuration만 import, properties 타입은 켜지 않음)에서 ca-skeleton.persistence.vendor=mysql을 준다.

shipped.unknownVendor.contextFailed=false
shipped.unknownVendor.vendorSettingsBeans=0
shipped.unknownVendor.sqlStateErrorMappingBeans=0
shipped.unknownVendor.postgreSqlConfigBeans=0
shipped.unknownVendor.h2ConfigBeans=0

같은 모양에 @EnableConfigurationProperties(PersistenceVendorSettings.class)만 추가하면 문서가 약속한 실패가 실제로 난다.

enabled.unknownVendor.contextFailed=true
enabled.unknownVendor.mentionsProperty=true

(context가 남긴 예외: ConfigurationPropertiesBindException: ... Could not bind properties to 'PersistenceVendorSettings' : prefix=ca-skeleton.persistence)

selector를 아예 주지 않은 경우에는 PostgreSQL 쪽이 활성화된다는 것도 같은 probe에서 확인된다 — Spring Data가 4개 repository interface를 스캔하고 entityManagerFactory 부재로 실패하므로, @Import(PersistenceJpaConfig.class) 사슬이 실제로 돌았다는 뜻이다. matchIfMissing = true는 살아 있다.

JpaAdapterComponentsConfig까지 넣은 네 번째 case에서는 context가 실패하지만 실패 메시지 어디에도 vendor property가 등장하지 않는다.

components.unknownVendor.contextFailed=true
components.unknownVendor.mentionsVendorProperty=false

판정: P2 confirmed. 오타 난 vendor 값은 startup을 실패시키기는 하지만, 그 실패는 property를 지목하지 않는다 — PersistenceVendorSettings가 막겠다고 선언한 바로 그 증상이다. app-bootstrap의 PersistenceVendorProdSafetyValidator도 도움이 되지 않는다. 그 validator는 prod profile에서 값이 h2인지만 보고 알 수 없는 값은 통과시킨다.

수정은 작다 — config package 안에 @EnableConfigurationProperties(PersistenceVendorSettings.class)를 가진 configuration을 두고 PersistenceJpaRootAutoConfiguration이 그것을 import하면, 이미 존재하는 두 sibling과 같은 모양이 된다. regression은 unknown vendor로 context를 띄워 실패 메시지가 property 이름을 포함하는지 보면 된다(위 probe가 그대로 red/green 쌍이다).

한계: probe context는 full application context가 아니다. 실제 배포에서 첫 번째로 실패하는 bean은 다를 수 있다. 증명된 것은 (a) unknown 값이 아무것도 bind하지 않고 두 vendor configuration을 모두 비활성으로 남긴다, (b) 그 경로의 실패가 property를 지목하지 않는다, (c) properties 타입을 켜면 지목하는 실패가 난다 — 세 가지다.

117. always-install scan과 opt-in scan의 경계는 실제로 지켜지고 있다

PersistenceJpaConfig는 persistence root를 통째로 스캔하지 않고 20개 package를 열거한다. 빠진 것은 config, h2(JPA stereotype 없음)와 opt-in 두 개(notification, fileserver)다. 두 opt-in은 각자의 @ConditionalOnProperty configuration이 자기 package만 스캔한다. 이 배치의 이유는 javadoc과 PersistenceEntityScanCoverageTest에 기록돼 있다 — 과거에 root를 스캔해서 capability를 끈 배포가 ddl-auto=validate에서 notification_request / fs_cleanup_item을 요구하며 부팅에 실패했다.

측정 결과 always-install scan의 건전성은 유지되고 있다. @Entity 25개 중 opt-in package(notification 13, fileserver 6) 밖의 4개는 idempotency_record, outbox_event, live_event_log, durable_operation이고, 이 네 테이블은 모두 default location db/migration/postgresql(V1/V3/V11/V12)이 만든다. postgresql package는 scan 대상이지만 그 안의 candidate adapter들(inbox, outbox v2, idempotency v2)은 @Entity가 아니라 native SQL 기반이라 persistence unit에 들어오지 않는다. 즉 sub-scope 08이 발견한 "opt-in stream을 always-install scan이 끌고 들어온다" 유형의 결함은 현재 남아 있지 않다.

다만 PersistenceEntityScanCoverageTest가 지키는 범위에는 비대칭이 하나 있다. opt-in configuration 두 개에 대해서는 @EntityScan 목록과 @EnableJpaRepositories 목록이 정확히 같은지 containsExactly로 검사한다("entities without repositories is half a scan, and fails at the first query"). 그런데 always-install PersistenceJpaConfig에 대해서는 @EntityScan 목록만 읽어 디스크와 대조하고, 두 목록의 일치는 검사하지 않는다. 현재 두 목록은 20개로 동일하다(110-... §H, diff 결과 identical). 그래서 지금은 무해하지만, 새 package를 @EntityScan에만 추가하는 실수는 이 test가 잡지 못한다 — 그 test가 opt-in 쪽에 대해서만 명시적으로 막고 있는 바로 그 실수다. P3.

CandidateAdapterCompositionTest는 반대 방향을 지킨다. PostgreSQL 전용 candidate adapter 3종에 @Repository/@Component/@Service가 없는지, 그리고 이미 stereotype을 제거한 PostgreSqlOwnerSafeIdempotencyStore가 계속 그 상태인지 검사한다. sub-scope 05가 이 adapter들을 "미조립 candidate"로 분류한 근거가 이 test로 고정돼 있다.

118. Negative-space probes — governance scope

근거: evidence/raw/108-governance-config-reachability.txt.

118.1 Public surface reachability

config package의 public type 3개는 모두 leaf 밖 소비자가 있다.

type leaf 밖 소비자
PersistenceJpaConfig sample-portfolioSamplePostgreSqlPersistenceConfig@Import. app-bootstrap은 직접 import하지 않고 두 vendor configuration을 통해 간접 도달
JpaAdapterComponentsConfig PersistenceJpaRootAutoConfiguration@Import 목록
PersistenceVendorSettings PersistenceJpaRootAutoConfigurationVENDOR_PROPERTY 상수만 사용(타입 자체는 bean 아님), application.yml 주석

configJpaModuleBoundaryTest.EXPORTED_PACKAGES에 들어 있으므로 이 도달은 선언된 export를 통한 것이다. zero-reference public type은 없다.

118.2 Conditional sibling comparison

두 축에서 비교했다.

  • @ConfigurationProperties 3형제: §116. 셋 중 하나만 enablement가 없다 — 비대칭이 확인된 결함이다.
  • configuration 활성화 조건: PersistenceJpaConfigJpaAdapterComponentsConfig는 조건이 없고, PostgreSqlPersistenceConfig/H2PersistenceConfig는 vendor 조건, NotificationJpaPersistenceConfig/FileserverJpaPersistenceConfig는 capability 조건을 갖는다. 무조건인 둘은 composition root의 JPA master switch(ca-skeleton.persistence-jpa.enabled) 뒤 @Import로만 도달하므로 "off는 구조적 사실"이라는 설계가 유지된다.
118.3 Duplicate-mechanism sweep

entity/repository/component scan 선언을 repository 전체에서 훑었다. persistence 관련 선언은 5곳이다 — leaf의 PersistenceJpaConfig(always), NotificationJpaPersistenceConfig, FileserverJpaPersistenceConfig, JpaAdapterComponentsConfig(component scan만), 그리고 sample-portfolio의 자기 package 전용 JpaConfig. app-bootstrap production에는 persistence entity/repository scan이 없다. test 쪽 2곳(OutboxContainerTestSupport, FileserverRoundTripContractTest 주석)은 harness 소유다. 경쟁 구현은 없다.

118.4 Documentation / measured-count drift

§119에서 따로 다룬다.

119. Confirmed documentation / measured-count drift

근거: evidence/raw/110-governance-doc-count-drift.txt.

이 repository는 leaf count drift를 잡는 전용 gate를 갖고 있다. root build.gradleverifyDocumentedLeafCount(\d+)\s*(?:개\s*)?-?\s*(?:leaf|leaves) 패턴을 찾아 registry의 실제 leaf 수와 비교하고, check에 연결돼 있다. 그 gate의 주석 자체가 "named list missed five module CLAUDE.md files and four leaf build.gradle headers, each restating 19-leaf from before the messaging platform's leaves were registered"라고 과거 사고를 기록한다.

그런데 gate의 탐색 domain은 CLAUDE.md와 (root를 뺀) build.gradle 두 파일명뿐이다. README.md, docs/**, *.java는 들어가지 않는다. 그 사각지대에 stale claim이 그대로 남아 있다.

항목 문서가 말하는 값 측정값 위치
registered leaf 19 44 docs/jpa/repository-adaptation.md:21, :101, JpaModuleBoundaryTest.java:20
같은 claim(형제 leaf) 19 44 httpclient / mongo / cache-redis boundary test 3개
public top-level type "318 of 324 production files" 338 / 350 JpaModuleBoundaryTest.java:124, CleanArchitectureTest.java:1110
package root dev.caskeleton.adapter.persistence dev.caskeleton.adapter.outbound.persistence README.md:3, :77
module 이름 adapter-persistence-rdbms registry id adapter-outbound-persistence-jpa README.md:1

verifyDocumentedLeafCount는 실제로 실행했고 통과한다(114-governance-pool-original-verification.txt, BUILD SUCCESSFUL, 9 actionable executed). 즉 gate가 green인 상태에서 leaf 자신의 module-boundary test와 leaf 자신의 adaptation 문서가 44개 registry를 19개라고 말하고 있다. docs/jpa/repository-adaptation.md는 leaf build.gradletasks.named('test') { inputs.file(...) }로 up-to-date 입력에 명시한 살아 있는 문서라 더 눈에 띈다.

public type 수는 확인 가능한 측정값이다. docs/architecture/jpa-api-surface.txt의 committed baseline이 스스로 # types: 338을 적고 있고 비주석 항목도 338개다. production Java 파일은 350개다. 문서의 318/324는 두 값 모두 과거치다.

반대로 drift가 아닌 것도 기록해 둔다. CLAUDE.md §Platform lanes가 나열한 7개 lane task 이름은 전부 실재하고, root jpaReleaseGatebuild.gradle:1097에 등록돼 있다. lane 이름 쪽 문서는 현재 정확하다.

판정: P3 documentation/count drift(확정). 코드 동작에는 영향이 없다. 영향은 신뢰도다 — module boundary test의 도입 주석이 module registry 크기를 절반 이하로 말하고 있고, 그 숫자를 잡으려고 만든 gate는 java 파일을 보지 않는다. 수정은 두 갈래다. 숫자를 고치거나(값이 또 늙는다), gate가 권하는 대로 "registry가 목록의 소유자"라고 명사를 바꾸거나. gate의 탐색 domain을 *.java/docs/**로 넓히는 쪽이 근본적이지만, 그러면 위 6개 파일이 즉시 red가 되므로 함께 고쳐야 한다.

120. Sub-scope 01 findings backlog

우선순위 finding reachability
P2 PersistenceVendorSettings가 production에서 @ConfigurationProperties bean으로 등록되지 않아, 문서가 약속한 "unknown vendor는 startup에서 property를 지목하며 실패" 계약이 shipped composition에 없다 shipped composition — 모든 JPA-on 배포
P3 verifyDocumentedLeafCount의 탐색 domain(CLAUDE.md/build.gradle) 밖에서 19-leaf claim 6곳이 생존, registry는 44 문서/주석; build는 green
P3 JpaModuleBoundaryTest/CleanArchitectureTest의 "318 of 324 production files" 측정 주석이 현재 338/350과 불일치 주석
P3 README.md가 package root를 dev.caskeleton.adapter.persistence로, module을 adapter-persistence-rdbms로 적음 문서
P3 PersistenceEntityScanCoverageTest가 always-install configuration의 @EntityScan / @EnableJpaRepositories 목록 일치를 검사하지 않음(opt-in 쪽은 검사) latent — 현재 두 목록 동일
P3/기록 JpaModuleBoundaryTest.PACKAGE_CATALOGpostgresql -> "inbox" 항목이 존재하지 않는 top-level package를 가리켜 사문 무해(엄격 방향)

121. Sub-scope 01 완료 조건

  • denominator 11 / 11 FULL_READ (107-...)
  • public surface reachability / conditional sibling / duplicate mechanism 3종 probe 수행(108-...)
  • documentation/count drift 재측정 및 확정(110-...), gate 실행 결과 포함(114-...)
  • 실행 probe 1건(109-..., 109a-...)으로 P2 확정, 원본 source 복구 후 git status --short clean
  • original source에서 leaf unit lane 재실행 green(114-...)

122. Sub-scope 12 범위와 denominator

내부 상태: COMPLETE — 3 / 3 FULL_READ 범위: src/jpaPlatformPerformanceTest (268 Java LOC) Gradle lane: jpaPlatformPoolContractTest

파일 라인 컨테이너
platform/pool/HikariPoolSaturationContractTest.java 119 자체 PG 16
platform/pool/RequiresNewPoolPressureContractTest.java 100 자체 PG 16
platform/pool/PoolPressureContractTest.java 49 없음

manifest: evidence/raw/111-persistence-jpa-pool-lane-manifest.txt. PoolLaneClaimTest(lane 이름/문구 drift guard)와 PoolMeasurement, PostgreSqlContainerFactory는 각각 sub-scope 06·10 소유라 여기서는 cross-scope 참조로만 쓴다.

123. 이 lane의 역사는 이미 한 번 교정됐다

lane 이름은 jpaPlatformPerformanceTest였고, "pool pressure certification"을 한다고 서술됐으며, performance.assertions.enabled flag 뒤에 있었다 — 그런데 그 flag는 build에서도, 그것을 명시적으로 끄던 nightly job에서도 기본값이 false였다. build.gradle의 주석이 그 결과를 직설적으로 적는다. "the release gate depended on a lane whose only threshold assertion was that thresholds were not being asserted".

교정은 세 갈래로 이루어졌다. task 이름을 jpaPlatformPoolContractTest로 바꾸고, flag를 제거하고, PoolLaneClaimTest가 lane을 설명하는 6개 파일에 그 flag 이름과 "certification"/"machine bounds" 문구가 남아 있지 않은지 텍스트로 검사한다. flag 이름은 test 자신이 검색 대상이 되지 않도록 세 조각으로 나눠 상수를 만든다 — 자기 자신을 매칭하는 guard는 통과할 수 없다는 것까지 고려돼 있다.

124. 남아 있는 문제 — lane이 "행동 계약"이라고 부르는 것 중 둘은 산술 항등식이다

교정 뒤에도 lane의 8개 test 중 2개는 데이터베이스도, pool도 보지 않는다.

PoolPressureContractTest.requiresNewNeedsTwoConnectionsPerThread():

int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1;   // 8 * 2 + 1
assertThat(required).isEqualTo(17);

RequiresNewPoolPressureContractTest.sizingRuleMatchesTheObservedRequirement():

int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1;   // 1 * 2 + 1
assertThat(required).isEqualTo(3);

두 test 모두 공식을 test 안에서 다시 쓴 다음 그 결과를 상수와 비교한다. CLAUDE.md의 pool-sizing 공식이 바뀌어도 이 assertion은 실패하지 않는다. 특히 두 번째는 @DisplayName이 "the sizing rule matches the observed requirement"인데, 같은 class가 관측한 요구치는 2다(pool 1은 실패, pool 2는 성공). 공식의 답 3과 관측치 2를 비교하는 assertion은 없고, 주석이 "the rule adds headroom"이라고 차이를 설명할 뿐이다.

PoolPressureContractTest.reportsPendingAndAcquireLatencyTogether()도 손으로 만든 PoolMeasurement(4, 2, 3, 80ms)의 accessor를 확인한다. 이는 record 계약 검증이지 pool 관측이 아니다.

판정: P3. lane은 jpaPlatformReleaseGate의 구성원이므로 여기서 green이 나는 것이 release 판단에 들어간다. 다만 lane의 실질 가치는 나머지 6개(컨테이너 기반) test가 만들고, 이 2개는 그 위에 얹힌 항등식이다. flag를 없앤 교정이 "측정하지 않는 것을 측정한다고 말하지 않기"였다면, 이 두 개는 그 교정이 닿지 않은 잔여물이다.

125. Confirmed P2 — nightly workflow가 광고하는 세 가지 중 하나를 lane이 실제로 관측하지 않는다

.github/workflows/jpa-nightly.yml:122-129는 이 lane이 검사하는 것을 세 가지로 적는다.

that REQUIRES_NEW needs two connections per concurrent thread, that a saturated pool reports its pending count, that a caller waits rather than proceeding without a connection

세 번째와 첫 번째는 실제 assertion이 있다. 두 번째는 없다. lane 전체에서 pending()/saturated()를 assert하는 곳은 PoolPressureContractTest의 손으로 만든 record 하나뿐이고, 실제 pool에서 getThreadsAwaitingConnection()을 읽는 유일한 지점(HikariPoolSaturationContractTest.measurementReportsPoolState)은 포화되지 않은 pool(size 2, 1개 점유)에서 읽은 뒤 active()==1total()>=1만 확인하고 pending에 대해서는 아무 assertion도 하지 않는다.

실행 probe

evidence/raw/113-pool-lane-saturation-probe.txt / 113a-...java. 실제 PostgreSQL 16 + Hikari(size 2)에서 두 연결을 점유하고 세 번째 요청 스레드를 대기시킨 뒤 측정했다.

realPool.active=2
realPool.idle=0
realPool.pending=1
realPool.saturated=true
realPool.waiterOutcome=acquired

즉 이 주장은 검증 가능하며 현재 검증되지 않고 있다. 참고로 손으로 만든 fixture 상태(active=4, idle=2, pending=3)는 실제 포화 pool이 보여준 조합(idle=0)과 다르다.

같은 probe에서 acquisition 경계도 측정했다.

acquire.configuredTimeoutMillis=500
acquire.observedWaitMillis=504
acquire.outcome=SQLTransientConnectionException
acquire.assertedUpperBoundMillis=2500

HikariPoolSaturationContractTest의 javadoc은 "a further acquisition must fail within the configured timeout"이라고 쓰지만 assertion 상한은 ACQUIRE_TIMEOUT.plusSeconds(2) = 2,500ms — 설정값의 5배다. 실제 동작은 504ms로 설정값에 4ms 붙어 있다. CI 여유를 감안해도 상한이 400% 넓어서, 500ms 설정에도 2초를 기다리게 되는 회귀는 이 assertion을 통과한다. P3.

126. release gate 소속은 양방향으로 검증되지 않는다

근거: evidence/raw/112-pool-lane-claim-registry-reachability.txt.

config/jpa/release-registry.json의 gate는 6개이고 모두 blocking이다.

postgresql-contract            :adapter:outbound:persistence-jpa:jpaPlatformContractTest
completion-unknown-no-retry    :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
osiv-disabled                  :app-bootstrap:test
flyway-validate                :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
runtime-role-no-ddl            :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
collection-fetch-pagination    :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest

pool lane은 registry에도, docs/jpa/support-matrix.md §Release gates 6행에도, testkit JpaReleaseGate.required()에도 없다(세 곳 모두 grep exit=1). 그런데 jpaPlatformReleaseGatedependsOn jpaPlatformPoolContractTest를 갖고, root jpaReleaseGate가 그것을 다시 의존한다.

verifyJpaReleaseGateTasks는 registry → task graph 한 방향만 검사한다(registry의 각 gate가 실제 Test task로 resolve되는가). 반대 방향 — release gate에 들어 있는 lane이 registry에 있는가 — 은 어디서도 검사되지 않는다. 따라서 jpaPlatformReleaseGate에서 pool lane 의존을 지워도 어떤 verifier도 반응하지 않고, 남는 실행 경로는 nightly workflow 한 줄뿐이다.

이것을 결함으로 올리지는 않는다. jpaPlatformReleaseGate의 주석이 밝힌 집계 기준은 "documented gate가 검증되지 않은 채 통과하게 만드는 lane"이고, pool lane은 문서화된 gate를 뒷받침하지 않으므로 기준상 registry에 없는 것이 일관적이다. 다만 그 결과로 이 lane의 release gate 소속만은 아무 계약도 보호하지 않는다는 사실을 기록한다. P3.

127. Fresh verification evidence — sub-scope 12

  • evidence/raw/114-governance-pool-original-verification.txt — original source에서 :adapter:outbound:persistence-jpa:test --rerun-tasks BUILD SUCCESSFUL in 28s / 18 executed, jpaPlatformPoolContractTest --rerun-tasks BUILD SUCCESSFUL in 24s / 18 executed, root verifyDocumentedLeafCount --rerun-tasks BUILD SUCCESSFUL in 3s / 9 executed, git clean before/after
  • evidence/raw/113-... / 113a-... — 실제 포화 pool의 pending/saturated 관측, acquisition 대기 504ms 대 설정 500ms 대 assertion 상한 2,500ms

128. Sub-scope 12 findings backlog

우선순위 finding reachability
P2 nightly workflow가 lane의 검증 항목으로 명시한 "a saturated pool reports its pending count"를 어떤 assertion도 실제 pool에서 확인하지 않음(손으로 만든 record만 확인) release gate 구성 lane; 관측 가능함을 probe로 확인
P3 PoolPressureContractTest/RequiresNewPoolPressureContractTest의 sizing-rule assertion 2개가 공식을 test 안에서 재작성한 뒤 자기 자신과 비교하는 항등식 실패할 수 없는 assertion
P3 saturation timeout assertion 상한이 설정값의 5배(2,500ms vs 500ms)여서 javadoc이 말하는 "within the configured timeout"을 강제하지 않음 회귀 탐지 폭
P3/기록 pool lane이 release gate 구성원이면서 release registry·support matrix·JpaReleaseGate 어디에도 없어, 소속이 양방향으로 검증되지 않음 governance

129. Sub-scope 12 완료 조건

  • denominator 3 / 3 FULL_READ (111-...)
  • lane claim / release-gate 소속 / assertion 실체 3종 대조(112-...)
  • 실제 PostgreSQL probe 1건(113-..., 113a-...), 원본 복구 후 git clean
  • original source lane 재실행 green(114-...)

130. Sub-scope 11 범위와 denominator

내부 상태: COMPLETE — 75 / 75 FULL_READ 범위: src/postgresqlIntegrationTest (71 Java + 4 SQL, 13,977 lines) 역할: 이 leaf에서 "실제 PostgreSQL이 답해야만 하는 주장"의 증거 생산자 전부

manifest: evidence/raw/120-persistence-jpa-integration-lane-manifest.txt.

package 파일 성격
platform/** (experimental 5 포함) 33 design §11~§40 계약, tag 기반 lane
readiness/** 29 readiness card producer + tag lane 혼재
notification/** 8 notification 저장소 계약 + fixture
operation/, liveevent/ 2 durable operation / live event 저장소 계약
resources/db/readiness/** 4 SQL Flyway 시나리오 fixture(중단·롤링)

이 sub-scope는 sub-scope 10(testkit)과 같은 성격이다 — production 동작이 아니라 evidence 생산자의 정확성을 분석한다. 다만 규모가 다르다. testkit이 62파일 3,070 LOC였다면 여기는 75파일 13,977 LOC이고, 이 leaf가 "H2로는 만족시킬 수 없다"고 선언한 모든 계약이 여기에 있다.

131. 이 source set 안에 서로 다른 두 개의 evidence 세계가 있다

파일은 한 source set에 있지만 실행 경로는 둘로 갈라진다.

(1) tag lane. @Tag("jpa-contract" | "jpa-migration" | "jpa-failure" | "jpa-queryplan" | "jpa-security")를 단 클래스는 registerJpaPlatformLane이 만든 5개 Test task가 includeTags로 고른다. 이 5개가 jpaPlatformReleaseGate와 release registry의 blocking gate에 연결된다.

(2) readiness card. @Tag가 없는 클래스는 registerPostgreSqlReadinessTest가 클래스 이름으로 하나씩 등록한 14개 task가 filter.includeTestsMatching으로 고른다. 이 task들은 config/jpa/readiness-cards.yaml의 card가 readiness-task / support-tasks로 지목하고, generateJpaEvidenceManifests가 active card의 producer를 모두 dependsOn한다.

두 세계의 대응은 정확히 맞아떨어진다. @Tag가 없는 test 클래스는 13개, readiness task가 이름으로 지목하는 클래스는 14개이고, 그 차이 1개는 PostgreSqlNotificationSchemaActivationIntegrationTest — 유일하게 tag와 readiness task를 둘 다 가진 클래스다. 즉 orphan test class는 0개다. --dry-run으로 실제 task graph를 resolve해 확인했다.

$ ./gradlew :adapter:outbound:persistence-jpa:generateJpaEvidenceManifests --dry-run
… postgresqlAggregateIntegrationTest … postgresqlFileserverMetadataIntegrationTest
… postgresqlFileserverMigrationIntegrationTest … postgresqlFileserverReclamationIntegrationTest
… (14개 전부 SKIPPED 로 등장)

Fileserver migration/reclamation 두 task는 card의 readiness-task가 아니라 jpa-fileserver-metadata-v1support-tasks로 들어와 있어서 실행은 되지만 evidence.task-claims가 비어 있어 어떤 required-evidence tag도 덮지 않는다. 실행은 fail-closed, 증거 연결은 없음 — 결함은 아니고 배선의 성격이다.

반대로 jpaPlatformReleaseGate --dry-run에는 readiness task가 하나도 없다. 그리고 .github/workflows 전체에서 postgresql*IntegrationTest task를 직접 부르는 곳도 없다(grep exit=1). readiness 세계의 유일한 자동 실행 경로는 ci-quality-gates.ymljpa-candidate-evidence job이 부르는 verifyJpaCandidateEvidencejpa-r2-evidence.ymlverifyJpaPrimaryFoundationEvidence 둘뿐이다. 이 구조가 §132의 결함이 오래 보이지 않은 이유다.

card 자체의 수치도 재측정했다. 총 17개 card 중 not-implemented 4개를 뺀 13개가 active(selected 7 + implemented-candidate 6)이고, generateJpaEvidenceManifests는 그중 jpa-primary-foundation을 제외한 12개의 readiness-task를 의존한다. README.md가 "active card 11개의 producer를 실행"이라고 적은 것과는 어긋난다(작은 count drift, P3).

132. Confirmed P1 — selected base card jpa-flyway-migration의 producer가 현재 revision에서 실패한다

postgresqlMigrationIntegrationTestjpa-flyway-migration card의 readiness-task다. 이 card는 state: selected이고 jpa-primary-foundation(R2 집계 gate)의 prerequisite 6개 중 하나다. 원본 소스, --rerun-tasks, git clean 상태에서 실행하면 BUILD FAILED다.

근거: evidence/raw/117-flyway-migration-readiness-lane-failure.txt, 118-readiness-task-result-matrix.txt.

PostgreSqlMigrationIntegrationTest > adoptsImmutableLegacyHistoryThenRunsTheIndependentCoreStream() FAILED
PostgreSqlMigrationIntegrationTest > freshCoreStreamInitializesWithoutLegacyHistory() FAILED
taskExit=1

두 실패의 정체는 같다 — stream에 migration이 추가됐는데 그 stream의 applied set을 고정한 assertion이 갱신되지 않았다.

Expecting actual:
  ["1", "3", "4", "5", "6", "9", "10", "11", "12"]
to contain exactly (and in same order):
  ["1", "3", "4", "5", "6"]
but some elements were not expected:
  ["9", "10", "11", "12"]
Expecting actual:
  ["1", "2"]
to contain exactly (and in same order):
  ["1"]
but some elements were not expected:
  ["2"]

앞은 db/migration/postgresql(legacy adoption stream, 현재 9개 파일), 뒤는 db/migration/jpa/core(현재 2개 파일)다. history로 시점을 맞춰 보면 원인이 분명하다.

2026-07-31  PostgreSqlMigrationIntegrationTest.java      ← assertion 최종 수정
2026-08-15  db/migration/jpa/core/V2__widen_capability_schema_stream.sql
2026-08-15  db/migration/postgresql/V9__widen_capability_schema_stream.sql
2026-08-18  db/migration/postgresql/V10__idempotency_request_hash_varchar.sql
2026-08-28  db/migration/postgresql/V11__durable_operation.sql
2026-08-28  db/migration/postgresql/V12__live_event_log.sql

4주에 걸쳐 5개 migration이 두 stream에 들어오는 동안 이 lane의 assertion은 한 번도 갱신되지 않았다. 같은 source set의 PostgreSqlOptionalStreamLifecycle은 이 실패 유형을 정확히 알고 있다 — notification stream의 버전 목록 주석에 "the version that landed without being added is why the lane failed the first time anybody ran it"라고 적혀 있다. 그 교훈이 base stream 쪽 lane에는 적용되지 않았다.

드러나지 않은 이유는 §131의 구조다. 이 task는 5개 tag lane 어디에도 속하지 않고, jpaPlatformReleaseGate에도 없고, 어떤 workflow도 이름으로 부르지 않는다. 같은 실행에서 5개 tag lane은 244 tests / 0 failures로 전부 green이었다. 이 lane을 실제로 도는 자동 경로는 verifyJpaCandidateEvidence 하나뿐이고, 그것이 실패하면 jpa-candidate-evidence CI job이 red가 된다.

같이 실행한 나머지 13개 readiness task 결과는 다음과 같다(118-...).

task                                                 tests skip failures errors verdict
postgresqlAggregateIntegrationTest                    1     0    0        0      PASS
postgresqlFileserverMetadataIntegrationTest          12     0    0        0      PASS
postgresqlFileserverMigrationIntegrationTest          6     0    0        0      PASS
postgresqlFileserverReclamationIntegrationTest       15     0    0        0      PASS
postgresqlIdempotencyIntegrationTest                  8     0    0        0      PASS
postgresqlInboxIntegrationTest                        5     0    0        0      PASS
postgresqlLifecycleIntegrationTest                    2     0    0        0      PASS
postgresqlMigrationIntegrationTest                    4     0    2        0      FAIL
postgresqlNotificationSchemaActivationIntegrationTest 8     0    0        0      PASS
postgresqlOutboxPollingIntegrationTest                5     0    0        0      PASS
postgresqlOutboxStorageIntegrationTest                5     0    0        0      PASS
postgresqlQueryIntegrationTest                        1     0    0        0      PASS
postgresqlSecurityBaselineIntegrationTest             3     0    1        0      FAIL
postgresqlTransactionIntegrationTest                  7     0    0        0      PASS

postgresqlSecurityBaselineIntegrationTest의 실패는 분석 환경 제약이지 결함이 아니다. verifyFullAcceptsTrustedHostAndRejectsHostnameMismatchAndUntrustedCertificatePostgreSqlTlsMaterialCN=localhost / SAN=DNS:localhost로 발급한 인증서를 verify-full로 검증하므로 컨테이너의 매핑 포트가 테스트 JVM의 loopback에서 열려 있어야 한다. 이번 분석은 Docker 소켓을 공유하는 형제 컨테이너 안에서 실행돼 매핑 포트가 Docker 브리지(172.17.0.1)에만 열렸고, 실패는 java.net.ConnectException이다. 이 lane은 skip이 아니라 실패하도록 설계돼 있으므로(no-skip) 동작 자체는 의도대로다. 다만 "no-skip"의 대가로 Docker 호스트와 테스트 JVM이 loopback을 공유하는 환경이 이 lane의 암묵적 전제가 된다는 사실은 기록해 둔다.

판정: P1 confirmed. 수정은 assertion을 stream의 현재 applied set으로 갱신하는 것이고, 재발 방지는 PostgreSqlOptionalStreamLifecycle이 이미 쓰는 방식(stream별 버전 목록을 한 곳에 고정)을 base stream에도 적용하는 것이다. 더 근본적으로는 이 lane이 tag lane과 완전히 분리돼 있다는 구조 자체가 재검토 대상이다 — 5개 tag lane이 green이라는 사실이 readiness lane의 상태에 대해 아무것도 말해주지 않는다.

133. Confirmed P2 — selected base card 3개의 evidence tag가 production code 없는 fixture로 충족된다

card의 required-evidence tag는 evidence.scenarios[].covers로 실제 JUnit selector에 연결된다. 그 연결을 실제 test 본문과 대조하면 세 card에서 tag와 증거의 격차가 나온다.

jpa-observability-lifecycleobservability PostgreSqlLifecycleIntegrationTest.poolCapacityExhaustionAndShutdownAreBoundedAndObservable이 이 tag를 덮는다. 관측 assertion은 다음 한 줄이다.

assertThat(saturated.boundedTags())
    .containsExactlyInAnyOrderEntriesOf(
        Map.of("component", "postgresql-primary", "state", "saturated"));

boundedTags()는 같은 파일 안의 private record PoolSnapshot의 메서드이고, 비교 대상 literal도 같은 파일에 있다. postgresql-primary라는 문자열은 repository 전체에서 이 파일 두 줄에만 존재한다(108-... 계열 검색과 별개로 git grep 확인). production의 persistence metric tag는 JpaMetricTags가 만드는 persistence.unit / persistence.operation / persistence.query / outcome / failure.category 5종이고 component tag도, pool 상태 metric도 없다. 즉 이 card의 observability 증거는 production 계측이 하나도 없어도 그대로 green이다. pool 자체의 포화/종료 동작(active 2, 종료 후 0, closed)은 실제로 관측하므로 lifecycle 쪽 증거는 유효하다 — 문제는 observability tag가 그 위에 얹혀 있다는 점이다.

jpa-query-modelquery-contract, query-plan 유일한 scenario PostgreSqlQueryIntegrationTest.boundedKeysetQueryUsesTheRepresentativeIndexreadiness_query 테이블·인덱스·쿼리를 test가 직접 만든다. production의 KeysetPageRequest/KeysetSlice/springdata keyset 실행 경로는 한 줄도 지나지 않는다. 게다가 plan 확인 직전에 set enable_seqscan=off를 실행한다 — 대안을 제거한 상태에서 "인덱스를 쓴다"를 확인하는 것이라, 인덱스나 쿼리 모양이 나빠도 index scan이 가능하기만 하면 통과한다. 같은 source set에는 production PostgreSqlExplainRunner/QueryPlanAssertions로 실제 plan 구조와 추정 오차를 보는 PostgreSqlQueryPlanContractTest가 있는데, card는 그쪽을 가리키지 않는다.

jpa-aggregate-storemapping, optimistic-conflict 유일한 scenario PostgreSqlAggregateIntegrationTest.roundTripsUuidAndInstantAndDetectsExpectedVersionConflictreadiness_aggregate 테이블에 대해 raw JDBC로 UUID/timestamptz 왕복과 update ... where version = ?가 0행을 반환하는 것을 확인한다. JPA entity도, @Version도, Hibernate optimistic locking도, production repository도 없다. JPA aggregate store card의 mapping 증거가 JPA를 거치지 않는다. 이쪽 역시 같은 source set에 production 경로를 쓰는 JpaValueMappingContractTest(Hibernate + MappingEntity)와 OptimisticRetryIntegrationTest(production OptimisticConflictTranslator + 실제 버전 충돌)가 있다.

이 셋을 하나로 묶는 사실은 다음과 같다. card scenario가 가리키는 클래스 12개는 전부 readiness/** 안에 있고, platform/**의 33개 계약 test 중 card가 가리키는 것은 0개다. 강한 증거 생산자와 card evidence가 서로 다른 세계에 있고, tag는 약한 쪽에 붙어 있다.

판정: P2 confirmed. 대비되는 반례가 같은 card 집합 안에 있다는 점이 판단을 쉽게 해 준다 — jpa-transaction-runtime의 7개 scenario는 production SpringTransactionPort + PostgreSqlLocalTimeoutConfigurer + PersistenceExceptionTranslator를 실제 서버에서 돌리고, deadlock 40P01, serializable 재시도, lock/statement timeout 경계, pool admission 거부, pg_terminate_backend로 만든 commit 유실의 INDETERMINATE 판정까지 확인한다. 즉 이 결함은 체계적인 것이 아니라 세 card에 국한된다. 수정은 tag를 옮기는 문제다 — 이미 존재하는 강한 test를 scenario로 등재하거나, 약한 scenario의 covers에서 과대 tag를 떼는 것.

134. notification contract fixture는 하나의 stream을 세 갈래로 다시 만든다

같은 source set 안에서 notification schema를 만드는 방법이 두 가지다.

  • readiness/PostgreSqlNotification*은 Flyway를 classpath:db/migration/jpa/notification-platform에 겨눈다 — stream 전체가 자동으로 따라온다. PostgreSqlNotificationSchemaActivationIntegrationTest는 한 걸음 더 나아가 production NotificationSchemaStream을 호출하며 그 이유를 적는다("a test that restates them proves that two authors agreed rather than that the stream is right").
  • notification/*ContractTest는 migration 파일 이름을 손으로 나열Statement.execute로 돌린다. 그리고 그 목록이 세 벌 있고 셋 다 길이가 다르다.
목록 소유자 notification 버전 주석
NotificationFixtures.migrations() V1V9 "The whole notification stream, in order… Applying a subset certifies a schema nobody deploys"
RecipientClaimContractTest.migrations() V1V8 "The whole stream, in order. Applying a subset certifies a schema nobody deploys — … which is how the first version of this test discovered that it was testing a database that could not exist."
ProjectionFactDurabilityContractTest.migrations() V1V6 (주석 없음)
(배포 stream) V1V10 PostgreSqlOptionalStreamLifecycle.notificationPlatform()이 applied set 0..10으로 고정

세 목록 모두 "the whole stream"이라고 말하고, 셋 다 아니다.

실행 probe

evidence/raw/119-notification-migration-ladder-probe.txt / 119a-...java. 각 사다리를 실제 PostgreSQL 16에 적용하고 information_schema.columnsnotification% 테이블의 컬럼 집합을 비교했다.

ladder.notificationVersions=6  columns=208
ladder.notificationVersions=8  columns=213
ladder.notificationVersions=9  columns=218
ladder.notificationVersions=10 columns=218

ladder.9.missing=[]
ladder.8.missing=[notification_delivery_attempt.provider_acceptance,
                  notification_delivery_attempt.provider_acceptance_certainty,
                  notification_delivery_attempt.provider_response_received_certainty,
                  notification_delivery_attempt.request_body_committed_certainty,
                  notification_delivery_attempt.request_started_certainty]
ladder.6.missing=[notification_admin_audit.claimed_at,
                  notification_admin_audit.command_fingerprint,
                  notification_admin_audit.phase,
                  notification_delivery_attempt.provider_acceptance,
                  notification_delivery_attempt.provider_acceptance_certainty,
                  notification_delivery_attempt.provider_response_received_certainty,
                  notification_delivery_attempt.request_body_committed_certainty,
                  notification_delivery_attempt.request_started_certainty,
                  notification_request.collapse_key,
                  notification_request.collapse_scope]

읽는 방법은 이렇다.

  • V1–V9는 현재 배포 형상과 컬럼이 동일하다(218 = 218). V10이 DDL 없는 guard(variables_payload가 base64 envelope가 아닌 행이 있으면 RAISE EXCEPTION)이기 때문이다. 그래서 NotificationFixtures를 쓰는 5개 test는 지금은 배포 형상 위에서 돈다. 다만 stream보다 한 칸 뒤에 있으므로 다음에 DDL을 가진 migration이 들어오는 순간 조용히 어긋난다.
  • V1V8은 evidence-certainty 5개 컬럼이 없다. RecipientClaimContractTestnotification_recipient_delivery만 다루므로 현재 false-green은 없다.
  • V1V6은 admin-claim 3 + evidence-certainty 5 + collapse 2, 총 10개 컬럼이 없다. ProjectionFactDurabilityContractTest는 V6가 도입한 projection fact 컬럼만 다루므로 역시 현재 false-green은 없다.

판정: P3. 현재 잘못된 통과를 만드는 경로는 확인되지 않는다. 문제는 유지보수 계약이다 — stream에 migration을 하나 추가하려면 네 곳(Flyway location은 자동, 나머지 세 목록은 수동)을 맞춰야 하고, 세 목록은 이미 각각 1·2·4 버전씩 뒤처져 있다. 그리고 세 목록의 주석이 모두 "subset은 아무도 배포하지 않는 schema를 인증한다"고 경고하고 있다. 수정 방향은 같은 source set이 이미 보여 준다 — Flyway location을 겨누거나 production NotificationSchemaStream을 호출하면 목록 자체가 사라진다.

부수적으로, notification/*ContractTest의 fixture는 variables_payload에 평문 '{}'를 넣는다. 이는 V10 guard가 거부하는 모양이다(guard는 migration 시점의 기존 행만 보므로 지금은 충돌하지 않는다). at-rest 계약이 "불가능하다"고 선언한 형상 위에서 contract test가 도는 셈이라, V10을 목록에 넣는 순간 fixture도 함께 바뀌어야 한다.

135. JpaPlatformContractSupport의 컨테이너 수명 서술은 실제와 다르다

클래스 javadoc은 이렇게 말한다.

The containers are shared for the JVM: the contracts verify server behaviour, which does not change between test classes, and starting a server per class turns a three-version matrix into minutes of container startup.

실제 사용은 정확히 그 "per class"다. JpaPlatformContractSupport.start() 호출 지점은 31곳이고 대부분 @BeforeAll에서 시작해 @AfterAll에서 close()한다. JVM 수준 공유 인스턴스나 static holder는 없다. StablePostgreSqlMatrixContractTest는 test마다, JpaPlatformContractSupportOwnershipTest는 test마다(5개) 컨테이너를 새로 띄운다.

실측치는 다음과 같다(115-integration-lane-original-verification.txt, XML의 Testcontainers 로그 집계).

lane 클래스 tests skipped failures PostgreSQL 컨테이너 기동
jpaPlatformContractTest 36 182 0 0 49
jpaPlatformMigrationTest 10 43 0 0 33
jpaPlatformFailureTest 2 6 0 0 2
jpaPlatformQueryPlanTest 1 3 0 0 1
jpaPlatformSecurityTest 2 10 0 0 2
합계 51 244 0 0 87

한 번의 전체 tag lane 통과에 PostgreSQL 컨테이너가 87번 기동한다. 그럼에도 5개 lane 전체가 3분 10초에 끝났으므로 비용 주장이 무너지는 수준은 아니다. 기록하는 이유는 서술과 구현의 불일치다 — 클래스가 자기 설계 근거로 내세운 "JVM 공유"가 소비자 31곳 어디에서도 성립하지 않는다. P3.

같은 클래스의 다른 서술은 사실이다. multi-version 선택을 fail-closed로 거부하는 것(start()selected.size() != 1이면 예외), 그리고 "the CI matrix fans out"은 jpa-release.yml(16/17/18), jpa-pr.yml(16/18), jpa-nightly.yml-Pjpa.matrix.versions로 실제 fan-out하는 것으로 확인된다. JpaPlatformContractSupportOwnershipTest가 지키는 pool 소유권(호출당 새 pool을 만들어 참조를 잃던 과거 결함)도 실제 assertion으로 고정돼 있다.

136. 이 lane이 실제로 강한 지점

결함만 나열하면 이 corpus를 오해하게 된다. 다음은 "실서버가 아니면 성립하지 않는" 주장을 실제로 실서버에서 확인하는 사례이고, 대부분 production 클래스를 그대로 쓴다.

  • commit ambiguityCommitAmbiguityContractTestpg_terminate_backend로 백엔드를 죽인 뒤 commit해서 실제 SQLSTATE가 57P01(class 08이 아님)임을 확인하고, production CommitFailureClassifier가 그것을 TransactionCompletionUnknownException / retryable=false로 번역하는지 본다. "연결이 끊기면 08일 것"이라는 합리적 추측이 왜 틀리는지가 주석에 적혀 있다.
  • transaction runtimePostgreSqlTransactionIntegrationTest가 production SpringTransactionPort로 transaction-local timeout 적용/복원, 확정 롤백, serializable 충돌의 replay-safe 정책 한정 재시도(action 호출 3회), 결정적 deadlock 40P01 단일 희생자, lock/statement timeout 경계(55P03/57014), pool 고갈 시 애플리케이션 작업 시작 전 거부, commit 중 연결 유실의 INDETERMINATE+재시도 금지를 모두 실측한다.
  • owner-safe idempotency / same-store inbox / immutable outbox / polling delivery — 네 readiness test 모두 production adapter를 실제 transaction 안에서 돌리고, 가상 스레드로 두 caller를 경쟁시켜 "첫 business commit 전에는 경쟁자가 owner row를 통과하지 못한다"를 실제로 블로킹시켜 확인한다.
  • RLS 실패 모드RlsIsolationFailureTest가 enable-but-not-forced일 때 소유자가 정책을 우회하는 고전적 false-green, session-scope 바인딩이 pool 반납 후 다음 차용자에게 새는 것, transaction-scope 바인딩은 새지 않는 것을 실제 pool로 구분한다.
  • schema/mapping 정합PostgreSqlDefaultPersistenceUnitIntegrationTestPersistenceJpaConfig@EntityScan 목록을 읽어서 ddl-auto=validate를 돌린다. 스캔 목록이 늘면 검사도 함께 늘어난다. notification 쪽도 같은 방식이 있고, 추가로 jsonb_typeof로 "JSON처럼 보이는 text"가 아닌지까지 본다.
  • evidence certaintyEvidenceCertaintyContractTest가 5×5×5×5 = 625조합을 실제 행에 왕복시키고, DB CHECK가 UNKNOWN인데 값이 true인 행과 모델 밖 certainty를 거부하는지 확인한다.

RecipientClaimContractTest는 방법론 면에서 이 corpus의 모범이다 — production RecipientClaimSql.CLAIM_BATCH 텍스트를 그대로 가져와 placeholder만 바꿔 쓰고, 그 이유를 "Retyping the SQL here would prove that two authors agreed about a query rather than that the query is right"라고 적는다.

137. 이전 sub-scope 발견과의 교차 정합

이 sub-scope의 파일들은 앞선 sub-scope가 올린 결함이 왜 lane에서 잡히지 않았는지를 직접 설명한다.

앞선 발견 이 lane 쪽 대응 사실
§52 (sub-scope 04, P1) collection-fetch gate가 SQL limit을 보지 않음 HibernateCollectionFetchPaginationContractTest.oneCollectionPageIsBoundedInSql은 반환 페이지 크기와 expected.requiresDatabaseLimit()(기대 객체 자신의 상수)만 확인한다. javadoc은 "The assertion is therefore on the generated SQL"이라고 쓴다. 파일 소유는 sub-scope 11, 결함 판정은 §52 — 중복 계상하지 않는다
sub-scope 10 (P1 latent) PostgreSqlExplainRunner가 data-modifying CTE를 허용 PostgreSqlQueryPlanContractTest.refusesNonSelect가 확인하는 것은 평범한 update 한 건뿐이다. CTE 형태는 이 assertion의 사각지대
sub-scope 06 (P1) Stable runtime-role 검증이 startup에서 실제 policy를 적용하지 않음 production requireSafe 호출자는 0. 유일한 호출자는 PostgreSqlSecurityContractTest.policyAcceptsVerifiedRole이고, 그 role과 policy는 test가 만든 것이다. blocking gate runtime-role-no-ddl이 green이라는 사실은 verifier가 동작한다는 뜻이지 배포가 그것을 부른다는 뜻이 아니다
sub-scope 09 (P1 latent) database-per-tenant budget이 이질적 pool 크기에서 ceiling 초과 TenantPoolCapacityContractTestPOOL_SIZE_PER_TENANT = 2균일한 pool만 연다. 이질적 조합이 lane에 없다는 것이 그 결함이 green으로 남은 이유다
sub-scope 08 (P2) V8 atomic admin claim에 production caller 0 AdminOperationClaimContractTestINSERT ... ON CONFLICT DO NOTHINGtest가 직접 작성해 검증한다(RecipientClaimSql 방식과 대조적). claim SQL이 옳다는 것과 production이 그것을 부른다는 것은 별개다

138. finding으로 올리지 않은 관찰

  • order-dependent test. ConstraintRaceContractTest.exactlyOneRowSurvives는 앞선 test가 넣은 행에 의존한다. XML의 실행 순서상 현재는 race test가 먼저 돈다. JUnit 기본 순서는 결정적이지만 명세된 계약이 아니고, 같은 source set의 PostgreSqlOutboxStorageIntegrationTest@TestMethodOrder(OrderAnnotation)로 명시한다. 한 corpus 안에 명시적 순서와 암묵적 순서 의존이 공존한다.
  • 삼킨 예외. PostgreSqlUpsertContractTest.upsertConcurrentlySQLException/InterruptedException을 기록 없이 삼킨다. 두 스레드 중 하나가 죽어도 count(*) == 1은 성립하므로 "동시 upsert가 수렴했다"와 "하나만 돌았다"를 구분하지 못한다.
  • display name과 assertion 불일치. PostgreSqlWorkClaimContractTest.claimsAreDeterministicallyOrdered의 이름은 결정적 순서를 약속하지만 assertion은 isNotEmpty() 하나다.
  • Docker 없이 도는 tag lane 클래스 2개. 51개 중 JpaAuditingContractTestJpaPlatformContractSupportTest만 컨테이너를 하나도 띄우지 않는다. 후자는 그 사실을 javadoc에 적어 두었고, 전자는 순수 단위 assertion 4개다. blocking gate postgresql-contract("The real database ran the contract suite, not H2")의 test 수 182에는 데이터베이스를 만난 적 없는 assertion이 섞여 있다.
  • credential fixture 3종. JpaPlatformContractSupport.generatedPassword()는 "리터럴은 committed credential"이라는 이유로 존재하는데, 소비자는 RlsIsolationFailureTest 하나다. PostgreSqlSecurityContractTest(blocking gate 생산자)는 password 'contract_runtime' 리터럴을 쓰고, PostgreSqlSecurityBaselineIntegrationTestUUID.randomUUID()pg_temp 함수에 파라미터로 넘긴다. 세 형제가 세 방식을 쓴다.
  • 중복 assertion. commitAmbiguityHasThreeDistinctInjectionPointsCommitAmbiguityContractTestPostgreSqlConcurrencyFailureContractTest에 거의 같은 형태로 두 번 있다(같은 jpa-failure lane).
  • PostgreSqlInboxCutoffIntegrationTest의 SQL 재작성. production InboxItemJpaRepository.markAllRead의 native @Query를 test가 다시 타이핑했다. 현재 두 문장은 술어 구조가 일치하지만, RecipientClaimSql 방식이 아니라 "두 저자가 합의했음"을 증명하는 형태다.
  • 죽은 helper. PostgreSqlNotificationInvariantIntegrationTest.unused(...)@SuppressWarnings("unused")와 함께 의도적으로 남아 있다.

139. Fresh verification evidence — sub-scope 11

  • evidence/raw/115-integration-lane-original-verification.txt — 원본 소스에서 5개 tag lane --rerun-tasks BUILD SUCCESSFUL in 3m 10s, 51 클래스 / 244 tests / 0 skipped / 0 failures / 87 컨테이너 기동, git clean before/after. 이어서 실행한 verifyJpaCandidateEvidence:app-bootstrap:testComposeMergeCharacterizationTest에서 멈춘다 — 그 test는 docker compose 유무만 assume으로 확인하고 스크립트가 요구하는 jq는 확인하지 않으며, 분석 컨테이너에 jq가 없다(스크립트 직접 실행 시 jq is required). app-bootstrap 소유 사안이자 환경 제약이므로 이 sub-scope의 결함으로 계상하지 않는다
  • evidence/raw/116-readiness-lane-original-verification.txt — 14개 readiness task --rerun-tasks --continue, git clean before/after
  • evidence/raw/117-flyway-migration-readiness-lane-failure.txtpostgresqlMigrationIntegrationTest 단독 --rerun-tasks BUILD FAILED / taskExit=1, 두 assertion 실패 원문, stream 파일 목록, migration/assertion 최종 수정일 대조
  • evidence/raw/118-readiness-task-result-matrix.txt — 14 task × tests/skipped/failures/errors 표, 12 PASS / 2 FAIL
  • evidence/raw/119-... / 119a-... — notification 사다리 4종의 컬럼 집합 실측과 차집합
  • evidence/raw/120-persistence-jpa-integration-lane-manifest.txt — 75/75 파일 해시·라인수, tag 분포, 무-tag 클래스 목록, readiness task 등록 목록

모든 임시 분석 test는 실행 후 삭제했고 최종 git status --short는 clean이다.

140. Sub-scope 11 findings backlog

우선순위 finding reachability
P1 selected base card jpa-flyway-migration의 producer postgresqlMigrationIntegrationTest가 HEAD에서 실패(base/legacy stream 5개 migration 추가 후 applied-set assertion 미갱신). tag lane·release gate 어디에도 속하지 않아 5개 lane green과 무관 CI jpa-candidate-evidence job의 유일 실행 경로; jpa-primary-foundation(R2)의 prerequisite
P2 base card 3종(observability / query-contract+query-plan / mapping+optimistic-conflict)의 evidence tag가 production code를 지나지 않는 fixture로 충족. query-planenable_seqscan=off 상태에서 판정 selected card 3개; 같은 source set에 더 강한 생산자가 존재
P3 notification contract fixture의 migration 사다리가 3벌(V1V9 / V1V8 / V1V6)로 갈라져 있고 배포 stream은 V1–V10. 실측 컬럼 차이 0 / 5 / 10 현재 false-green 경로 없음; 다음 DDL migration에서 어긋남
P3 JpaPlatformContractSupport javadoc의 "containers are shared for the JVM"이 소비자 31곳 어디에서도 성립하지 않음(실측 87 컨테이너 기동/전체 tag lane 1회) 서술/구현 불일치
P3 README.md의 "active card 11개" 대 실측 active 13 / producer 12 문서 count drift
P3 postgresql-contract blocking gate의 182 tests에 컨테이너를 띄우지 않는 클래스 2개가 포함 gate 문구 대 구성
P3 order-dependent test 1건, 삼킨 예외 1건, display-name과 assertion 불일치 1건, credential fixture 3방식, 중복 assertion 1쌍 §138
환경 제약(결함 아님) postgresqlSecurityBaselineIntegrationTest의 TLS scenario는 컨테이너 매핑 포트가 테스트 JVM의 loopback에 열려 있어야 성립(인증서 SAN이 localhost 단일). 형제 컨테이너 실행 환경에서는 ConnectException으로 실패 no-skip 설계의 암묵적 환경 전제

141. Sub-scope 11 완료 조건

  • denominator 75 / 75 FULL_READ (120-...), 71 Java + 4 SQL
  • lane 소속을 tag/Gradle task/card 세 축으로 대조하고 orphan test 0을 --dry-run task graph로 확인
  • negative-space probe: test 도달성(무-tag 클래스 ↔ readiness task 1:1), card evidence tag ↔ 실제 assertion 대조, 중복 mechanism(notification 사다리 4벌, SQL 재작성 대 production 상수), 문서/수치 drift(active card 수, 컨테이너 공유 서술, gate 문구)
  • 실행 evidence: 5개 tag lane 전량 재실행, 14개 readiness task 재실행, 실패 lane 단독 재현, notification 사다리 컬럼 실측
  • 앞선 sub-scope 04·06·08·09·10 발견과의 교차 정합을 lane 쪽 사실로 설명
  • 모든 임시 source 복구, git status --short clean

142. Module ledger 재조정과 module 완료 조건

142.1 최종 ledger
# sub-scope denominator status 주요 evidence
1 governance / build / docs / root boundary 11 COMPLETE 107110, 114
2 API contracts (api/**) 55 COMPLETE 031040
3 transaction + persistence failure 51 COMPLETE 041051
4 Spring Data + Hibernate + Querydsl 53 COMPLETE 052061
5 PostgreSQL vendor + vendor migrations 73 COMPLETE 062069
6 baseline capability stores/config/audit/cache/H2 등 87 COMPLETE 070076
7 Fileserver persistence + migrations 29 COMPLETE 077082
8 Notification persistence + migrations 68 COMPLETE 083092
9 Experimental platform 38 COMPLETE 093101
10 testkit + fixture tests 62 COMPLETE 102106
11 PostgreSQL integration/readiness lane 75 COMPLETE 115120
12 pool/performance contract lane 3 COMPLETE 111114
TOTAL 605 12 / 12

605 = leaf top-level 4 + src/main 381 + src/test 101 + src/testkit 41 + src/postgresqlIntegrationTest 75 + src/jpaPlatformPerformanceTest 3. 12개 sub-scope의 denominator 합도 605이며, 모든 tracked file이 정확히 하나의 sub-scope에 귀속된다. 중복 계상은 한 곳에서만 발생할 수 있었고(§114) sub-scope 06이 이미 계상한 3개 test를 sub-scope 01이 다시 세지 않는 것으로 정리했다.

module 전체 disposition은 605 FULL_READ / 0 STRUCTURAL_ONLY / 0 EXCLUDED / 0 UNCLASSIFIED다.

142.2 module-level 완료 조건 대조
  • 정량 denominator: 605 tracked file, src/main 350 Java ≈ 27,744 LOC, public top-level type 338, package 24, 독립 Flyway stream 7, 실서버 lane 5(tag) + 14(readiness) + 1(pool), release gate 6
  • coverage ledger: 미분류 0
  • top-level package map: 24개 전부 JpaModuleBoundaryTest.PACKAGE_CATALOG와 대조 완료
  • build/runtime wiring: composition root → PersistenceJpaRootAutoConfiguration → vendor config → PersistenceJpaConfig / JpaAdapterComponentsConfig 사슬을 §116–§117에서 실행 probe로 확인
  • invariant / failure path: sub-scope 02~10에서 문서화, sub-scope 11에서 실서버 증거와 대조
  • test ↔ claim 매핑: sub-scope 11이 lane·card·gate 세 축으로 완료
  • rationale 분리: 코드 주석·문서가 밝힌 역사(observed)와 분석 추론(inferred)을 각 절에서 구분
  • §8 4종 probe: 12개 sub-scope 전부에서 수행 또는 비적용 사유 기록
  • dead/unwired/duplicate: 정적 검색과 task graph resolve로 확인
  • documentation/count drift: §119(leaf), §132·§134·§135(evidence 계층)에서 재측정
  • 한계와 제외 영역: 각 sub-scope 완료 조건에 기재. module 수준 한계는 §142.3
  • improvement backlog: 12개 sub-scope backlog 유지
142.3 module 수준 한계
  • 정적 도달성 분석은 reflection, service loader, 생성 코드 등록, 이 repository 밖의 adopter를 증명하지 않는다. api package는 의도된 외부 surface이므로 내부 참조 0이 곧 dead를 뜻하지 않는다.
  • 실서버 증거는 PostgreSQL 16 단일 major에서 수집했다. registry가 Stable로 선언한 17·18은 CI matrix가 fan-out하며, 이 분석에서는 실행하지 않았다.
  • postgresqlSecurityBaselineIntegrationTest의 TLS scenario와 :app-bootstrap:test의 compose scenario는 분석 환경 제약으로 실행하지 못했다(§132, §139). 두 건 모두 원인을 특정했고 repository 결함으로 계상하지 않았다.
  • 성능·부하 특성은 이 분석의 대상이 아니다. pool lane은 행동 계약이지 측정 lane이 아니며(§123–§125), repository에도 threshold를 가진 성능 gate는 없다.
142.4 module findings 요약
우선순위 건수 출처 sub-scope
P1 (confirmed) 8 03(completion-evidence 미설치), 04(collection-fetch gate false evidence), 07(quota 미집행, schema activation 오인), 08(V4 schema 오인, lease fencing 우회), 11(flyway-migration lane 실패) 외
P1 latent / conditional 6 06, 07, 09, 10
P2 12+ 02, 03, 04, 05, 08, 09, 11, 12, 01
P3 / 기록 다수 전 sub-scope

module 전체에서 반복되는 단일 주제가 하나 있다. 구현은 계약을 정확히 서술하는데, 그 계약을 실제 배포나 실제 gate에 연결하는 마지막 한 칸이 비어 있는 경우다 — completion-evidence capability가 composition에 없고(§23), runtime-role verifier를 production이 부르지 않고(sub-scope 06), admin claim을 production이 쓰지 않고(sub-scope 08), vendor selector의 properties 타입이 켜지지 않고(§116), 그리고 그 미연결을 잡아야 할 evidence lane 자체가 4주간 실패한 채 아무도 실행하지 않았다(§132). 이 leaf의 다음 작업 우선순위는 새 기능이 아니라 이 마지막 한 칸들을 잇는 것이다.

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 230개 (main 143 · test 36 · 기타 51).

src/adapter/outbound/persistence-jpa/build.gradle
src/config/architecture/modules.json                    (adapter-outbound-persistence-jpa 항목)

main:
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationName.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/CapabilitySupport.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/JpaCapability.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConnectionUnavailableException.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintCode.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintViolationDetails.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/FailureCategory.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaEntityNotFoundException.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContext.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceException.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionCompletionUnknownException.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/VendorFailureTranslator.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorCodec.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetPageRequest.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetSlice.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/NoopQueryObservation.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryName.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryObservation.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryScope.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodec.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SortDirection.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaRetryPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDecision.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryEventListener.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryProfile.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionCompletionEvidence.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfile.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditContextPort.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntity.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/AuditMetadata.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditingConfiguration.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/config/JpaAdapterComponentsConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceJpaConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/HibernateEnversHistoryReader.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeature.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantEntityManagerFactoryRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantPoolBudget.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyAwareDataSourceRouter.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaLagMonitor.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsPolicyVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsTenantSessionBinder.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaMultiTenantConnectionProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantMigrationOrchestrator.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantAwareRepositoryGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantEntityListenerGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/FileserverJpaPersistenceConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/FileserverSchemaActivation.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaCleanupQueue.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaFileQuotaService.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaQuotaCommitGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaQuotaReclaimGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaRecoveryQueue.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/entity/QuotaReservationEntity.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/repository/FileserverCleanupRepository.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/repository/FileserverQuotaRepository.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateProviderPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollector.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsSnapshot.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/JdbcBatchCounter.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/NamedStatementInspector.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/QueryNameContext.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/BatchExecutionResult.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateJpaBatchExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfileRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/HibernateBulkDmlExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/HibernateStatelessSessionRunner.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessWorkResult.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/entity/IdempotencyRecordEntity.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/JpaLiveEventReplayAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/LiveEventJpaRepository.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/DistributedLockPersistenceConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/LockSettings.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationJpaPersistenceConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationSchemaActivation.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationSchemaStream.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/configuration/NotificationJpaPersistenceFacade.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/JdbcReconciliationJobStore.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/JpaAdminOperationStore.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/RecipientClaimSql.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/TenantBoundRepositoryGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/inbox/InboxItemJpaRepository.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTags.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaRetryObservation.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaTransactionObservation.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationJpaRepository.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationStoreAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxClaimRepository.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/package-info.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurer.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlExceptionTranslator.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifier.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/inbox/PostgreSqlSameStoreInboxAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlImmutableOutboxAppendAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlPollingDeliveryAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRange.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeCodec.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupport.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/security/PostgreSqlRuntimeRoleVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/security/SearchPathPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityGraphCatalog.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplier.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupport.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupport.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortField.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapper.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SpecificationPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifier.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecord.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorder.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManager.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettings.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/OptimisticConflictTranslator.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/PersistenceFailureTranslatorChain.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudget.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculator.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceContext.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceScope.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java
  src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifier.java

test:
  src/test/java/dev/caskeleton/adapter/outbound/persistence/CandidateAdapterCompositionTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationNameTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContextTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceExceptionTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryNameTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodecTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfileTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceEntityScanCoverageTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSelectionTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalEntryConsentTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/platform/PoolLaneClaimTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/IdempotencyDigestPolicyTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManagerTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java
  src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceScopeTest.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityExposureCondition.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRules.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaAuditMechanismRule.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/CommitAmbiguityProxy.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7Generator.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingDataSource.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityState.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbe.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/MappingEntity.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationContractRunner.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurement.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContractExtension.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/NormalizedPlan.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/PostgreSqlExplainRunner.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertions.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanExpectation.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseGate.java
  src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java

기타:
  CLAUDE.md
  README.md
  docs/architecture/jpa-api-surface.txt
  docs/fileserver/design-deviations.md
  docs/jpa/repository-adaptation.md
  docs/jpa/security.md
  docs/jpa/support-matrix.md
  docs/jpa/transaction-guide.md
  docs/reviews/2026-08-14-jpa-module-code-review.md
  src/build.gradle
  src/config/jpa/readiness-cards.yaml
  src/config/jpa/release-registry.json
  src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/HikariPoolSaturationContractTest.java
  src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/PoolPressureContractTest.java
  src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/RequiresNewPoolPressureContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/AdminOperationClaimContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/EvidenceCertaintyContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationFixtures.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/ProjectionFactDurabilityContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/RecipientClaimContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/CommitAmbiguityContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/ConstraintRaceContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateCollectionFetchPaginationContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateJpaBatchExecutorIntegrationTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/IdStrategyContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaAuditingContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupport.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupportOwnershipTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupportTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaValueMappingContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/OptimisticRetryIntegrationTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlConcurrencyFailureContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlQueryPlanContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSecurityContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlUpsertContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlWorkClaimContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/StablePostgreSqlMatrixContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/RlsIsolationFailureTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantPoolCapacityContractTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlAggregateIntegrationTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlDefaultPersistenceUnitIntegrationTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlInboxCutoffIntegrationTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlLifecycleIntegrationTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlNotificationInvariantIntegrationTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlNotificationSchemaActivationIntegrationTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOptionalStreamLifecycle.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxStorageIntegrationTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlQueryIntegrationTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java
  src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTransactionIntegrationTest.java

해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등:
  092-notification-reachability-test-gap.txt
  evidence/raw/103-testkit-unit-boundary-probes.txt
  evidence/raw/078-fileserver-quota-boundary-probe-output.txt
  evidence/raw/096-experimental-gate-reachability.txt
  099-experimental-structural-optin-gap.txt
  evidence/raw/097-experimental-replica-provider-probe.txt
  106-testkit-original-verification.txt
  evidence/raw/053-jpa-query-hibernate-boundary-probe.txt
  evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt
  evidence/raw/072-baseline-capability-reachability.txt
  evidence/raw/075-outbox-stale-worker-state-regression-output.txt
  evidence/raw/073-durable-operation-expired-lease-output.txt


A06. adapter-outbound-persistence-mongo

분석 중에는 06-adapter-outbound-persistence-mongo.md 파일이었다. 1,772줄.

adapter-outbound-persistence-mongo 상세 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-outbound-persistence-mongo
  • canonical state analysisFile: §A06 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/outbound/persistence-mongo · Gradle :adapter:outbound:persistence-mongo
  • registry allowed_dependencies: []
  • registry runtime_memberships: ["app-bootstrap"]
  • coverage ledger: FULL_READ 497 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


상태: COMPLETE
기준 revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
분석 범위: src/adapter/outbound/persistence-mongo
Gradle path: :adapter:outbound:persistence-mongo

0. 왜 내부 sub-scope로 나누는가

이 leaf도 persistence-jpa와 같은 이유로 한 번에 훑지 않는다. tracked file은 497개, production Java만 351개(약 22,927 LOC)이고, 설계 원본은 이것을 19개 Stable + 12개 Advanced Gradle module로 모델링한다. 이 저장소의 fail-closed registry가 그 배치를 대체하므로 module 경계는 dev.caskeleton.adapter.outbound.mongo 아래 package가 되고, package graph 자체가 내부 module graph 역할을 한다. 따라서 파일이 정확히 하나의 내부 bounded sub-scope에 귀속되도록 ledger를 먼저 고정한다.

전체 denominator
  • tracked leaf files: 497
  • leaf top-level: CLAUDE.md, README.md, build.gradle, gradle.lockfile (4)
  • src/main: 353 files / 351 Java / 2 resources / 약 22,927 LOC
  • src/test: 104 files / 약 12,380 LOC
  • src/testkit: 35 files / 약 3,036 LOC
  • src/mongoPerformanceTest: 1 file / 194 LOC
  • public top-level type: 346 (committed baseline docs/architecture/mongo-api-surface.txt가 스스로 # types: 346을 적고, 비주석 항목도 346개)

근거: evidence/raw/121-persistence-mongo-module-inventory.txt.

내부 bounded sub-scope ledger
# sub-scope main test testkit 기타 denominator status
1 governance / build / root boundary / autoconfigure 15 12 4 31 COMPLETE
2 api/** — framework-free core contract 61 9 70 COMPLETE
3 mapping + nativecap + geo 23 4 27 COMPLETE
4 imperative + reactive 실행 경로 47 14 61 COMPLETE
5 query + aggregation 22 7 29 COMPLETE
6 transaction (+ retry, session) 20 7 27 COMPLETE
7 schema + migration 49 9 58 COMPLETE
8 changestream 21 5 26 COMPLETE
9 security + failure + observation + client 30 14 44 COMPLETE
10 advanced/** 65 10 75 COMPLETE
11 testkit + architecture/rs/release/compat test + performance lane 13 35 1 49 COMPLETE
TOTAL 353 104 35 5 497 11 / 11

sub-scope 1의 main 15는 root package Java 4 + autoconfigure/** 9 + resources 2다. 합계는 497로 leaf tracked file 전체와 일치하며, 모든 파일이 정확히 하나의 sub-scope에 귀속된다.

이 ledger는 module completion 전까지 모든 tracked file의 최종 disposition(FULL_READ / STRUCTURAL_ONLY / EXCLUDED)을 추적하기 위한 내부 작업 단위다. module-level state.json은 11개가 모두 닫힐 때만 COMPLETE로 전환한다.

1. 모듈 구조의 1차 관찰

이 leaf는 opt-in이라는 한 가지 성질을 축으로 설계돼 있고, 그 성질이 나머지 모든 구조를 결정한다.

  • allowed_dependencies[]다. project dependency가 하나도 없고, 외부 의존은 Spring Boot의 Mongo starter(sync/reactive), autoconfigure, Micrometer, SLF4J뿐이다. verifyCleanArchitectureDependencies는 "실제 edge ⊆ 허용 edge"만 보므로 쓰이지 않는 허용은 영원히 통과한다 — 그래서 반대 방향을 보는 MongoRegistryPermissionParityTest가 따로 있다.
  • runtime_memberships["app-bootstrap"]이고, composition root가 실제로 이 leaf를 implementation으로 싣는다(reactive starter와 reactivestreams driver는 exclude). 즉 이 module은 jar에 들어 있고 property가 스위치다. CLAUDE.md/README가 이 선택을 명시적으로 방어한다 — "빠져 있는 모듈은 꺼진 모듈과 같은 계약이 아니다. 부재는 배포 시점에 되돌릴 수 없고, gating 결함을 전부 가린다."
  • JPA adapter와의 책임 분리가 선언돼 있다. idempotency / outbox / distributed lock은 Mongo에 재구현하지 않고 JPA에 남긴다.
  • production에 가짜 도메인(Example*)을 두지 않는다. 이 leaf가 제공하는 것은 client·template·정책 표면이고, document/repository/mapper와 port 구현은 fork가 추가한다. 이 선택은 뒤에서 반복적으로 나타난다 — 여러 계약이 "정책과 value object는 있으나 실행체는 fork가 공급한다"는 형태다.

docs/mongodb/repository-adaptation.md가 설계의 module 배치를 이 leaf의 package로 매핑한 기록이고, package 간 방향은 MongoModuleBoundaryTest가 닫힌 edge matrix로 강제한다. 이 문서는 각 sub-scope를 닫아가며 그 주장들과 실제 source/build/test/runtime evidence를 대조한다.


2. Sub-scope 01 범위와 denominator

내부 상태: COMPLETE — 31 / 31 FULL_READ
범위: leaf 최상위 4 + production root package 4 + autoconfigure/** 9 + auto-configuration 등록 resource 2 + 해당 test 12
역할: "이 애플리케이션이 MongoDB와 말하는가"를 결정하는 층 전체

구분 파일 라인
governance CLAUDE.md 167
rationale README.md 147
build build.gradle 283
build gradle.lockfile 192
production root MongoRootAutoConfiguration.java 37
production root MongoPersistenceConfig.java 27
production root MongoPersistenceSettings.java 38
production root MongoOptInAutoConfigurationImportFilter.java 59
production autoconfigure/** 9개 1,382
resource META-INF/spring.factories 2
resource META-INF/spring/…AutoConfiguration.imports 1
test root package 2 (MongoNamespaceContractTest, MongoPersistenceConfigTest) 202
test autoconfigure/** 10개 1,156

manifest: evidence/raw/122-mongo-governance-optin-manifest.txt.

3. opt-in은 네 겹이고, 각 겹이 서로 다른 실패를 막는다

무엇 왜 그 층이어야 하는가
Boot import filter MongoOptInAutoConfigurationImportFilter (spring.factories 등록) Mongo starter는 classpath만으로 auto-configuration 후보를 등록한다. project condition은 후보 선정 뒤에 평가되므로, 후보 단계에서 9개 Boot Mongo auto-configuration을 빼지 않으면 평범한 @EnableAutoConfiguration 앱이 client와 template을 만든다
auto-configuration entry MongoRootAutoConfiguration (AutoConfiguration.imports 등록) 마스터 하나. 예전에는 filter·component-scan된 config·platform auto-config 셋이 각자 같은 property를 읽는 마스터였고, 서로가 꺼져 있다고 믿는 것을 조립할 수 있었다
infrastructure MongoPersistenceConfig @ImportAutoConfiguration명시적 import라 spring.autoconfigure.exclude의 영향을 받지 않는다. 켠 프로필에서만 Mongo client/template을 다시 들여온다
platform MongoPlatformAutoConfiguration, MongoDriverObservabilityAutoConfiguration 정책 bean. 후자는 MeterRegistry가 있을 때만 driver listener를 붙인다 — publish할 곳 없는 listener는 모든 command에 비용만 얹는다

네 겹 모두 ca-skeleton.persistence-mongo.enabled=true라는 같은 조건을 읽는다(evidence/raw/123-... §8.2). 이것은 중복이 아니라 계층별 차단이다: filter는 Boot의 후보군, 나머지 셋은 자기 bean 그래프를 담당한다. MongoPersistenceConfigTest가 실제 @EnableAutoConfiguration context로 default/false에서 MongoClient·MongoTemplate 부재를, enabled=true + mock client에서 MongoTemplate 단일 bean을 확인한다.

MongoPlatformAutoConfiguration(443줄)은 이 leaf에서 가장 밀도가 높은 파일이고, 거의 모든 @Bean의 javadoc이 과거에 "shipped했지만 아무 configuration도 만들지 않던" 경로를 기록한다 — atomic/bulk template, reactive 실행 경로 일체, change-stream source와 consumer, startup validator, client generation registry, health indicator. 이 leaf는 그 미연결들을 한 번 훑어 고친 이력을 갖고 있고, 그 사실이 이 sub-scope의 판단 기준을 바꾼다: 남아 있는 미연결은 "아직 안 한 것"이 아니라 "훑고도 남은 것"이다.

startup 검증 쪽 설계도 눈여겨볼 만하다. mongoPlatformStartupCheckMongoTopologyProbe bean이 있을 때만 돌지만, 그 조건이 곧 탈출구가 되는 것을 막기 위해 mongoTopologyProbeRequirementprobe 조건 없이 등록되어 "platform profile이 있는데 probe가 없으면" 실패시킨다. javadoc이 그 이유를 한 줄로 적는다 — "a requirement that only applies when the thing it requires is present is not a requirement".

4. Confirmed P2 — README가 제시하는 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않는다

leaf README §활성화가 제시하는 전체 recipe는 두 줄이다.

ca-skeleton.persistence-mongo.enabled=true
spring.data.mongodb.uri=mongodb://localhost:27017/portfolio

이 두 줄에는 서로 독립적인 문제가 둘 있다.

(1) 필수 property가 빠져 있다. composition root의 CapabilityDependencyValidator는 Mongo가 켜져 있고 ca-skeleton.persistence-mongo.active-profile이 blank이면 violation을 만들고, CapabilityDependencyStartupCheck가 context refresh에서 그 violation으로 startup을 중단시킨다. 이 key는 app-bootstrap/src/main/resources/application.yml:370${APP_PERSISTENCE_MONGO_ACTIVE_PROFILE:}로 노출하고 .env.local.exampledocs/registries/env-keys.yaml도 required로 기록한다. 그런데 leaf에서 active-profile을 언급하는 파일은 0개다(123-... §8.3, exit=1). CLAUDE.md도 README도 이 key를 적지 않는다.

MongoPersistenceSettings가 이 key를 bind하지 않는 것 자체는 일관적이다 — 그 클래스는 "모듈의 opt-in 스위치만 소유한다". 문제는 key가 이 module의 property namespace(ca-skeleton.persistence-mongo.*) 안에 있으면서 소유·문서화가 전부 leaf 밖에 있고, leaf의 활성화 문서가 그것을 모른다는 점이다.

(2) 폐기된 namespace를 지시한다. §5에서 따로 다룬다.

판정: P2 confirmed. leaf의 활성화 문서를 그대로 따른 배포는 뜨지 않으며, 실패 메시지는 leaf 문서 어디에도 없는 property를 지목한다. 근거는 evidence/raw/125-... §D이고, 규칙이 실제로 강제된다는 사실은 app-bootstrap의 기존 CapabilityDependencyValidatorTest를 원본 상태로 재실행해 확인했다(126-..., BUILD SUCCESSFUL). 수정은 README/CLAUDE.md의 recipe에 active-profile을 추가하고 유효한 값의 출처(= ca-skeleton.persistence-mongo.platform.profiles의 key)를 함께 적는 것이다.

5. Confirmed P3 — 폐기된 namespace guard의 탐색 domain이 operator가 읽는 두 문서를 덮지 않는다

MongoNamespaceContractTest(MNG-INT-002)는 정확히 이 문제를 위해 존재하고, javadoc이 막으려는 defect를 이렇게 정의한다.

A sentence recording that the old namespace is deprecated is the opposite of the defect — the defect was a document telling an operator to use it.

그 guard의 탐색 domain은 다음과 같다(125-... §C).

  • adapter/outbound/persistence-mongoapp-bootstrap 아래
  • 경로에 /src/main/을 포함하는 파일만
  • .java주석을 제거한 뒤, .yml/.properties는 통째로

따라서 다음 세 곳은 domain 밖이고, 셋 다 spring.data.mongodb.를 담고 있다.

위치 내용
README.md:37 붙여넣기용 예제 spring.data.mongodb.uri=mongodb://localhost:27017/portfolio
README.md:53, CLAUDE.md:25 "URI/database/credential은 표준 spring.data.mongodb.* 설정을 사용한다"
src/test/.../MongoPersistenceConfigTest.java:20, :64 이 leaf 자신의 opt-in 대표 test가 spring.data.mongodb.database=portfolio를 사용

src/main 쪽은 깨끗하다 — 유일한 매치는 MongoPersistenceSettings의 javadoc이고, 그것은 "예전에 이 javadoc이 폐기 키를 가리켰다"는 기록이라 guard가 주석을 제거하는 이유 그대로다.

판정: P3 confirmed. guard가 막겠다고 명시한 형태(문서가 operator에게 폐기 키를 쓰라고 말하는 것)가 guard의 사각지대에서 그대로 살아 있고, 그중 하나는 복사해 쓰라고 제시된 예제다. 런타임은 영향받지 않는다 — Compose lane은 SPRING_MONGODB_URI를 공급하고, 폐기는 제거가 아니다. 수정은 두 문서의 키를 spring.mongodb.*로 바꾸고, guard의 domain에 leaf의 *.md를 추가하는 것이다(추가하면 위 세 곳이 즉시 red가 되므로 함께 고쳐야 한다).

6. Confirmed P3 — change-streams=true는 거부되지 않고 조용히 버려지며, 그 결과 startup validator의 한 분기가 production에서 도달 불가다

MongoPlatformSettings의 compact constructor는 세 입력을 서로 다르게 처리한다.

profiles = profiles == null ? Map.of() : Map.copyOf(profiles);   // 흡수
changeStreams = false;                                            // 무조건 덮어씀
if (requiredSecondaries < 0) { throw MongoOperationRejectedException.of(...); }  // 거부

changeStreams 자리의 주석은 이렇게 말한다 — "Accepting the flag and ignoring it would leave an operator believing it took effect, so the value is refused rather than stored: zero beans, zero threads, and a true that cannot be honoured never becomes one that looks honoured."

실제 동작은 refuse가 아니라 silent discard다. 임시 probe(evidence/raw/124-..., 124a-...)로 세 입력을 실제 binding에 통과시켰다.

changeStreams.contextFailed=false
changeStreams.boundValue=false
transactions.contextFailed=false
transactions.boundValue=true
negativeSecondaries.contextFailed=true
negativeSecondaries.failureType=dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException

즉 같은 생성자 안에서 required-secondaries=-1은 예외로 거부되고, 형제 flag transactions=true는 그대로 보존되며, change-streams=true만 예외 없이 false가 된다. operator는 자기가 켠 것이 꺼졌다는 신호를 받지 못한다 — 주석이 막겠다고 한 바로 그 상태다.

파생 결과가 하나 더 있다. MongoStartupValidatorchangeStreamsEnabled가 참일 때 topology capability를 검사하는 분기를 갖는데(MongoStartupValidator.java:104), production 생성 지점은 MongoPlatformAutoConfiguration.java:354 하나뿐이고 거기서 넘기는 값은 properties.changeStreams()다. 그 값은 위에서 항상 false이므로 이 분기는 shipped composition에서 도달할 수 없다. 도달하는 유일한 경로는 validator를 직접 생성하는 MongoStartupValidatorTest.java:143이다. 근거: 123-... §8.2b, §8.2c.

판정: P3 confirmed. 현재 잘못된 동작을 만들지는 않는다 — change stream 실행체는 애초에 shipped되지 않는다고 CLAUDE.md가 명시한다. 문제는 (a) 문서가 refuse라고 말하는 것이 discard이고, (b) 그 결과 capability 검사 한 갈래가 test에서만 살아 있다는 점이다. 수정은 두 방향 중 하나다 — 값을 정말로 거부하거나(requiredSecondaries와 같은 형태), 아니면 flag를 record component에서 제거해 존재하지 않는 스위치로 만드는 것.

7. Negative-space probes — governance / opt-in scope

근거: evidence/raw/123-mongo-optin-reachability-and-siblings.txt.

7.1 Public surface reachability

이 sub-scope의 production public type 13개 중 leaf 밖에서 참조되는 것은 둘뿐이다.

type leaf 밖 참조
MongoPlatformHealthIndicator app-bootstrapMongoPlatformHealthConfig, MongoPlatformHealthContributor (+ 해당 test)
MongoRootAutoConfiguration app-bootstrapShippedRuntimeFacadePresenceTest
나머지 11개 0

zero-reference를 dead로 읽어서는 안 되는 경우가 여기 있다. MongoRootAutoConfigurationMETA-INF/spring/…AutoConfiguration.imports가, MongoOptInAutoConfigurationImportFilterMETA-INF/spring.factories가 이름으로 등록한다 — 두 resource 모두 이 sub-scope가 소유하며 manifest에 포함돼 있다. MongoPersistenceConfig/MongoPlatformAutoConfiguration/MongoDriverObservabilityAutoConfiguration은 root의 @Import로 도달하고, settings 세 종류는 @EnableConfigurationProperties 인자로 도달한다. 즉 이 sub-scope의 도달성은 Java import graph가 아니라 등록 metadata와 annotation 인자에 있으며, 정적 참조 검색만으로는 판단할 수 없다.

7.2 Conditional sibling comparison

같은 master switch를 읽는 production 지점은 6곳이다 — root, persistence config, platform auto-config, driver observability auto-config, mapping configuration, advanced configuration. 앞의 넷은 §3의 계층별 차단이고, MongoMappingConfigurationMongoAdvancedConfiguration은 각각 sub-scope 3·10 소유이므로 그쪽에서 다시 본다. 이 sub-scope 범위에서는 조건 비대칭이 발견되지 않았다: 네 configuration이 모두 같은 prefix/name/havingValue를 쓴다.

property record 쪽에서는 비대칭이 하나 있고 §6에서 다뤘다.

7.3 Duplicate-mechanism sweep

ca-skeleton.persistence-mongo.* namespace를 소유하는 주체가 셋이다.

key 소유자 위치
.enabled MongoPersistenceSettings leaf root
.platform.* MongoPlatformSettings leaf autoconfigure
.advanced.* MongoAdvancedSettings / MongoAdvancedCapabilityFlags leaf advanced (sub-scope 10)
.active-profile 없음application.yml이 노출하고 CapabilityDependencyValidator가 요구 app-bootstrap

경쟁 구현은 없다. 다만 마지막 행이 §4의 결함이다 — 한 namespace의 네 번째 key만 소유자가 leaf 밖에 있고 leaf 문서가 그것을 모른다.

7.4 Documentation / measured-count drift

§8에서 따로 다룬다.

8. Confirmed documentation / measured-count drift

근거: evidence/raw/125-mongo-governance-doc-count-drift.txt, 126-mongo-hermetic-lane-original-verification.txt.

항목 문서가 말하는 값 측정값 위치
public top-level type / production 파일 "311 of this leaf's 313 production files" 346 / 351 build.gradle:260
hermetic contract test "382 hermetic contract tests" 526 (83 classes) build.gradle:87
registered leaf 19 44 MongoModuleBoundaryTest.java:16, docs/mongodb/repository-adaptation.md:18, docs/adr/ADR-MONGO-001:61

앞의 두 건은 같은 파일 안에서 서로를 반박한다 — build.gradle은 311/313을 적으면서 그 아래 apiSurface 블록으로 docs/architecture/mongo-api-surface.txt를 baseline으로 지정하고, 그 baseline은 스스로 # types: 346을 적는다. verifyMongoApiSurface는 baseline과 실제 surface를 비교하므로 green이면서 동시에 주석의 숫자가 틀릴 수 있고, 실제로 그렇다(126-...: verifyMongoApiSurface: OK — the committed public API surface is unchanged.).

contract test 수도 마찬가지다. 주석의 382는 두 lane이 겹쳐 돌던 시점의 값이고, 원본 상태에서 lane을 재실행한 측정값은 526이다. lane 분리 자체는 유효하다 — verifyMongoTestLaneDisjointness가 두 lane의 JUnit XML을 비교해 overlap 0을 확인하고 통과한다.

19-leaf claim은 persistence-jpa scope에서 확인한 것과 같은 사각지대다. verifyDocumentedLeafCount의 탐색 domain은 CLAUDE.md와 (root를 뺀) build.gradle 두 파일명뿐이라 *.javadocs/**를 보지 않는다. 이 leaf 쪽 생존 지점 3곳이 그 domain 밖이다.

drift가 아닌 것도 기록한다. README §의존성 경계는 "MongoModuleBoundaryTest(ArchUnit) 10개 규칙"이라고 쓰고 8개를 열거한다. 실제 파일의 @Test는 13개이며, 그중 10개가 방향 규칙(core-api framework 무의존, core-api ↛ 다른 platform package, Stable starter ↛ Advanced, Stable ↛ Advanced, imperative ↛ reactive, aggregation→query, production ↛ testkit, schema ↛ 실행 경로, observability→core-api only, migration ↛ engine adapter)이고 나머지 3개는 구조 검사(edge matrix가 디스크의 package 집합과 정확히 일치, 관측된 모든 edge가 선언된 것, 선언된 edge가 DAG)다. README의 "10개 규칙"은 방향 규칙 개수로 정확하다.

9. Sub-scope 01 findings backlog

우선순위 finding reachability
P2 leaf README의 활성화 recipe에 필수 ca-skeleton.persistence-mongo.active-profile이 빠져 있어, 그대로 따르면 CapabilityDependencyStartupCheck가 startup을 거부한다. 이 key를 언급하는 leaf 파일은 0개 문서를 따른 모든 신규 활성화
P3 MongoNamespaceContractTest의 domain(src/main/**의 java/yml/properties)이 leaf CLAUDE.md·README.mdsrc/test를 덮지 않아, guard가 정의한 defect(문서가 operator에게 폐기 키를 지시)가 붙여넣기용 예제로 생존 문서 3곳 + 자기 leaf test 2곳; 런타임 영향 없음
P3 MongoPlatformSettingschange-streams=true를 예외 없이 false로 덮어쓰면서 주석은 "refused"라고 서술. 형제 입력 required-secondaries=-1은 예외로 거부되고 transactions=true는 보존됨 모든 platform 설정 binding
P3 위의 결과로 MongoStartupValidator의 change-stream capability 분기가 production 생성 경로에서 도달 불가(production 생성 지점 1곳이 항상 false를 넘김) test에서만 도달
P3 build.gradle 주석의 측정치 2건 drift — "311 of 313 production files"(실측 346/351), "382 hermetic contract tests"(실측 526) 주석; gate는 green
P3 19-leaf claim 3곳(MongoModuleBoundaryTest, docs/mongodb/repository-adaptation.md, ADR-MONGO-001)이 registry 44와 불일치하며 verifyDocumentedLeafCount의 domain 밖 문서/주석

10. Fresh verification evidence — sub-scope 01

  • evidence/raw/126-mongo-hermetic-lane-original-verification.txt — 원본 소스, --rerun-tasks, git clean before/after
    • :adapter:outbound:persistence-mongo:test — 14 classes / 72 tests / 0 skipped / 0 failures
    • :adapter:outbound:persistence-mongo:mongoStableContractTest — 83 classes / 526 tests / 0 skipped / 0 failures
    • verifyMongoTestLaneDisjointness, verifyMongoReleaseContractLanes, verifyMongoApiSurface 모두 통과(verifyMongoApiSurface: OK — the committed public API surface is unchanged.), 17 actionable tasks executed
    • :app-bootstrap:test --tests '*CapabilityDependencyValidatorTest*' — BUILD SUCCESSFUL (§4의 활성화 규칙이 실제로 강제됨을 확인)
  • evidence/raw/124-... / 124a-... — platform settings binding probe 3 case, 임시 test는 실행 후 삭제하고 git status --short clean 확인

11. Sub-scope 01 완료 조건

  • denominator 31 / 31 FULL_READ (122-...)
  • opt-in 네 겹의 계층별 역할과 등록 metadata 도달성 확인(123-... §8.1)
  • conditional sibling(같은 master switch를 읽는 6개 production 지점, property record 3종)과 duplicate mechanism(ca-skeleton.persistence-mongo.* namespace 소유자 4주체) 비교 수행
  • documentation/count drift 재측정(125-...)과 gate 실행 결과 대조(126-...)
  • 실행 probe 1건(124-...)으로 P3 확정, 원본 복구 후 git clean
  • original source hermetic lane 2종 + governance gate 3종 + 활성화 규칙 test 재실행 green

12. 다음 sub-scope로 넘긴 것

  • api/** 61개 production type의 framework-free 계약과 MongoModuleBoundaryTest의 edge matrix 전수 대조 → sub-scope 2
  • MongoPlatformAutoConfiguration이 등록하는 각 bean의 구현 정확성(consistency binder, imperative/reactive executor, atomic/bulk policy, budget enforcer, failure translator) → sub-scope 4·5·9
  • change stream source/consumer 배선과 changeStreams flag의 관계 → sub-scope 8
  • MongoProfileProperties.validate()가 강제하는 production 계약(TLS·인증·Stable API·topology·타임아웃)의 실제 검증 범위와 security package의 credential resolver → sub-scope 9
  • Advanced capability gate(@MongoAdvancedEntryPoint, MongoAdvancedRules)와 flag binding → sub-scope 10
  • testkit 35개와 6개 Docker lane, release contract manifest → sub-scope 11

13. Sub-scope 02 범위와 denominator

내부 상태: COMPLETE — 70 / 70 FULL_READ 범위: src/main/java/**/api/** 61개(2,687 LOC) + 전용 test 9개 역할: Spring·driver·BSON·Reactor 없이 platform의 의미론을 고정하는 core contract

sub-package production dedicated test 역할
api root 7 2 operation identity, 실행 context, profile 이름
api.error 25 1 실행 결과·실패 분류·retry scope·예외 계층
api.mapping 9 1 BSON 표현 manifest
api.profile 5 1 client plane, topology, Stable API 선언
api.capability 5 2 capability 보고 vocabulary
api.consistency 4 1 consistency profile registry
api.schema 3 1 document schema version 정책
api.observation 3 0 관측 seam(no-op 포함)
합계 61 9 70

manifest: evidence/raw/127-mongo-api-scope-manifest.txt.

committed public API surface 346개 중 ...mongo.api.로 시작하는 것은 59개다(61에서 package-info.java와 package-private NoOpMongoOperationObserver를 뺀 수). 즉 이 leaf가 공개하는 타입의 17%만이 의도된 외부 계약이고 나머지 287개는 build.gradle과 CLAUDE.md가 스스로 "implementation that has not been moved under an internal root yet"라고 부르는 것들이다. 이 숫자는 두 문서의 서술과 일치하며, internal root 이전이 끝났을 때 표면이 실제로 줄었는지 판정할 기준점이 된다.

14. framework-free 규칙은 ArchUnit과 별개로도 성립한다

MongoModuleBoundaryTest.coreApiIsFreeOfSpringDriverBsonAndReactor()가 이 규칙을 강제하지만, rule이 vacuous하게 통과하는 경우를 배제하기 위해 소스 자체를 직접 훑었다.

$ git grep -n 'import org\.springframework\|import com\.mongodb\|import org\.bson\|import reactor\.' -- '…/mongo/api'
exit=1

61개 파일 전체에서 매치 0이다(128-... §8.1b). api.observation이 이 규칙의 비용을 가장 잘 보여 준다 — MongoOperationObserver는 core에 선언되고 Micrometer 구현은 경계 밖 observation package에 있으며, 그래서 실행 경로가 관측성 module에 의존하지 않고도 관측할 수 있다. NoOpMongoOperationObserver는 nullable 필드 대신 null object여서 "관측성 꺼짐" 경로가 켜짐 경로와 다른 코드로 갈라지지 않는다.

api/**를 leaf 밖에서 참조하는 파일은 0개다(§8.1). 이것을 dead로 읽어서는 안 된다 — 이 leaf는 의도적으로 가짜 도메인을 두지 않고, README가 "실제 프로젝트가 자신의 document/repository/mapper와 port 구현을 추가한다"고 선언한다. 즉 api는 저장소 안에 소비자가 없는 것이 설계된 상태다. 한계는 그대로 남는다: 정적 검색은 이 저장소 밖 adopter를 증명하지도 반증하지도 않는다.

15. 이 sub-scope의 중심 설계 — 두 개의 모호한 결과를 무너뜨리지 않는 것

CLAUDE.md가 platform invariant로 못박은 문장이 여기 구현돼 있다 — "MongoExecutionOutcome's two ambiguous values must not be collapsed into success or failure."

MongoExecutionOutcome은 boolean이 아니라 7값 enum이고, isAmbiguous()(WRITE_RESULT_UNKNOWN, TRANSACTION_COMMIT_UNKNOWN)와 forbidsBlindReplay()(여기에 PARTIAL_BULK_WRITE 추가)를 구분한다. READ_CONFIRMED가 별도 값으로 존재하는 이유도 주석에 있다 — 두 executor가 성공한 FINDWRITE_CONFIRMED로 기록해 모든 read가 확인된 write처럼 보였던 과거 결함이다.

그리고 이 의미론이 무너지지 않게 하는 방어가 예외 타입 두 개의 생성자에 있다.

  • MongoTransactionCommitUnknownException은 context가 commit-unknown·ambiguous·non-retryable이 아니면 IllegalArgumentException으로 거부한다.
  • MongoTransactionTransientException은 반대로 context가 commit-unknown이거나 ambiguous이면 거부한다.

두 javadoc이 막으려는 과거 상태를 그대로 기록한다 — session factory가 commitUnknown context를 먼저 만든 뒤 classifier가 고른 예외로 감싸는 바람에 "body를 재실행하라"는 예외가 "unknown commit, not retryable, ambiguous"라는 context를 들고 다녔다. 지금은 factory와 생성자 검사가 그 조합을 불가능하게 만든다.

production 경로도 일관적이다. DefaultMongoFailureTranslatorMongoFailureClassification(category+outcome+retryScope 삼중항)을 먼저 만들고 retryableclassification.bodyReplayAllowed(), ambiguousclassification.ambiguous()에서 파생한다. 즉 두 boolean이 scope와 어긋날 여지가 production 경로에는 없다.

16. Confirmed P2 — schema version 실패는 두 경로 중 어느 쪽도 온전하지 않다

MongoFailureCategory에는 이 실패를 위한 전용 값 SCHEMA_VERSION_UNSUPPORTED("The stored document's schema version is outside the supported range")가 있고, 전용 예외 MongoDataSchemaUnsupportedExceptiondocumentVersion / minimumSupported / currentVersion 세 정수를 공개 accessor로 노출한다. production 생성 지점은 정확히 둘이고, 각각 반쪽만 맞다.

생성 지점 category 세 버전 값
MongoSchemaVersionPolicy:85 (버전을 실제로 아는 유일한 곳) MongoFailureContext.rejected(...)OPERATION_REJECTED / outcome NOT_SENT 실제 값
DefaultMongoFailureTranslator:111 (전용 category를 붙이는 유일한 곳) SCHEMA_VERSION_UNSUPPORTED -1, -1, -1

MongoFailureCategory의 클래스 javadoc은 category가 "the value that appears in metrics and dashboards"라고 명시한다. 따라서 실제로 발생하는 schema-version 실패는 대시보드에서 OPERATION_REJECTED(= 로컬 guardrail 거절) bin에 들어가고, SCHEMA_VERSION_UNSUPPORTED bin은 세 버전이 -1인 실패만 받는다. 두 신호 모두 운영자가 필요로 하는 답을 주지 못한다 — 앞은 "어떤 종류의 실패인가"를, 뒤는 "어떤 버전이 문제인가"를 잃는다.

근거: evidence/raw/128-... §8.2c. 수정은 작다 — MongoSchemaVersionPolicy.unsupported(...)rejected(...) 대신 category SCHEMA_VERSION_UNSUPPORTED를 가진 context를 만들면 되고, 그러면 translator 쪽 -1 경로는 도달 불가 분기로 정리할 수 있다. regression은 정책이 던진 예외의 category()SCHEMA_VERSION_UNSUPPORTED인지 보는 한 줄이다.

같은 형태가 하나 더 있다. DefaultMongoFailureTranslator:106MongoDocumentTooLargeException-1L, -1L로 만든다. estimatedBytes()/budgetBytes()의 javadoc은 "Estimated serialized size. A size, not content: safe to log."라고만 적고 값이 없을 수 있다는 말을 하지 않는다. driver가 보고한 실패에서는 그 두 수를 알 수 없으므로 sentinel 자체는 불가피하지만, 계약에 그 사실이 없다. P3.

17. Confirmed P3 — 예외 계층의 "cause를 붙이지 않는다" 규칙에 문서화되지 않은 예외가 하나 있다

MongoPersistenceException의 javadoc은 두 번째 규칙을 절대적으로 서술한다.

Second, no constructor accepts a {@link Throwable} cause: attaching the driver exception would re-expose everything the failure context deliberately dropped, through getCause() and through every stack trace printer.

하위 타입 20개 중 하나가 이 규칙을 벗어난다. MongoTimeoutException은 2-arg 생성자에서 initCause(cause)를 호출한다(MongoTimeoutException.java:27).

실제 유출 표면은 좁다. 그 생성자의 유일한 호출처는 DefaultReactiveMongoExecutor:152이고, 넘기는 값은 Reactor 자신의 java.util.concurrent.TimeoutException이다 — driver 예외가 아니며 document·query·credential을 담지 않는다. 그리고 그렇게 감싸는 이유가 주석에 있다: 이전에는 raw TimeoutException이 그대로 새어 나가 operation도 outcome도 관측도 없이 호출자에게 도달했다.

문제는 계약 쪽이다. 규칙이 "어떤 생성자도 cause를 받지 않는다"로 쓰여 있으면 adopter는 MongoPersistenceException을 cause chain까지 통째로 로깅해도 안전하다고 읽는다. 그 판단의 근거가 되는 문장이 한 타입에 대해 거짓이고, 그 사실은 어디에도 적혀 있지 않다.

이 규칙을 검사하는 유일한 test는 MongoFailureContextTest.exceptionsDoNotExposeADriverCause()인데, 대상이 MongoTransactionCommitUnknownException — cause를 받는 생성자가 없는 타입이다. 즉 규칙은 그것을 깨지 않는 타입에 대해서만 단언되고, 유일하게 깨는 타입은 검사 밖이다. 근거: 128-... §8.2b.

수정은 둘 중 하나다 — root javadoc을 "driver 예외를 cause로 붙이지 않는다"로 좁히고 MongoTimeoutException의 예외를 명시하거나, cause를 붙이지 않고 Reactor timeout의 정보를 failure context에 흡수시키는 것. 어느 쪽이든 test는 "모든 MongoPersistenceException 하위 타입에 대해 cause가 driver/BSON 타입이 아니다"로 넓혀야 규칙과 검사가 같은 것을 말한다.

18. Negative-space probes — api scope

근거: evidence/raw/128-mongo-api-negative-space-probes.txt.

18.1 Public surface reachability

api/** 참조는 leaf 밖에서 0이고(§14), 그것이 설계된 상태다. 대신 이 sub-scope에서 실제로 의미 있는 도달성 질문은 api 타입을 소비하는 leaf 내부 경로가 존재하는가였고, 확인한 것들은 다음과 같다: MongoServerVersionschema/validation/MongoValidatorApplyPolicy:54(유일한 production 소비자), MongoRetryScopefailure/MongoFailureClassification + 두 transaction session factory + transaction/retry/MongoRetryDecision, MongoFailureContext factory 5종 → schema policy / type mapper / reactive executor / 두 session factory / retry coordinator. zero-consumer인 api 타입은 발견되지 않았다.

18.2 Invariant sibling comparison

같은 성격의 타입들이 불변식을 얼마나 강제하는지 비교했다.

타입 거부하는 것 거부하지 않는 것
MongoTransactionCommitUnknownException commit-unknown이 아닌 context
MongoTransactionTransientException ambiguous하거나 commit-unknown인 context
MongoFailureClassification COMMIT_ONLY + non-commit-unknown outcome 그 외 조합
MongoFailureContext null, attempt<1, 음수 elapsed outcome ↔ ambiguous 정합
MongoConsistencyDescriptor causal session + non-majority concern secondaryPreferred + majority write
MongoProfileProperties(sub-scope 1) production TLS/인증/topology/타임아웃

두 개의 빈칸이 이 sub-scope의 P3다.

(a) MongoFailureContextoutcome=WRITE_RESULT_UNKNOWN, ambiguous=false 같은 조합을 canonical constructor가 막지 않는다. MongoExecutionOutcome.isAmbiguous()가 이미 있으므로 한 줄이면 강제된다. 다만 실제 위험은 제한적이다: production 경로는 classification에서 파생하고(§15), 가장 위험한 두 쌍은 예외 타입이 생성 시점에 거부한다. 남는 노출은 api가 외부 표면이라 adopter가 record를 직접 만들 수 있다는 점이다.

(b) MongoConsistencyDescriptorMongoConsistencyProfile의 javadoc은 "A caller that picks majority write concern and secondaryPreferred reads has not chosen durability, it has chosen a bug"라고 그 조합을 명시적으로 bug라 부른다. 그런데 record의 compact constructor는 causal-session 규칙 두 개만 검사한다. MongoConsistencyRegistry.of(...)는 public이고 javadoc이 "used by tests and by profile overrides"라고 적으므로, 그 조합을 담은 descriptor를 등록하는 경로가 타입 수준에서 열려 있다. standard()가 만드는 6개 profile은 모두 정합적이므로 현재 결함은 아니다.

18.3 Duplicate-mechanism sweep

(a) 두 profile-name record가 검증 코드까지 동일하다. DatabaseProfileNameCollectionProfileName을 이름만 치환해 diff하면 남는 차이는 javadoc 문장뿐이고, FORMAT([a-z][a-z0-9-]{2,63}UUID_LIKE·생성자 검사·toString이 모두 같다. 같은 규칙이 두 벌 유지되므로 한쪽만 강화하면 조용히 갈라진다. P3/기록.

(b) retry 의미론이 두 표현으로 존재한다. MongoRetryScope의 javadoc은 "Encoding that as a scope rather than a retryable boolean is what stops the two from collapsing into one flag at the call site"라고 쓰는데, 같은 package의 MongoFailureContext는 정확히 boolean retryable을 필드로 갖는다. 다만 §15에서 확인했듯 production 경로에서 그 boolean은 scope에서 파생되고, 삼중항을 들고 다니는 타입(MongoFailureClassification)은 api가 아니라 failure package에 있다. 즉 이것은 결함이 아니라 경계 배치의 결과다 — framework-free core는 boolean만 들고, scope를 읽는 코드는 경계 밖에 있다. 기록만 한다.

(c) 자리표시자 profile 이름이 실제 이름의 값 공간을 공유한다. MongoOperationScope.UNSPECIFIED = "unspecified"DatabaseProfileNameFORMAT을 통과하는 평범한 값이라, unspecified라는 이름으로 실제 profile을 등록하면 isProfileResolved()가 그것을 미해결로 판정한다. 현재 그런 profile은 없다. P3/기록.

18.4 Documentation / measured-count drift

이 sub-scope 범위에서 새로 확인된 drift는 없다. api 표면 기여 59/346은 §13에서 실측했고, build.gradle 주석의 311/313 drift는 sub-scope 01(§8)에서 이미 확정했다.

19. Sub-scope 02 findings backlog

우선순위 finding reachability
P2 schema version 실패의 두 생성 경로가 각각 반쪽만 맞다 — 버전을 아는 경로는 category OPERATION_REJECTED, 전용 category를 붙이는 경로는 버전 -1,-1,-1 production 두 경로 모두; 대시보드 bin과 공개 accessor 값
P3 예외 계층의 "no constructor accepts a Throwable cause" 규칙을 MongoTimeoutException의 2-arg 생성자가 initCause로 벗어나며, 규칙을 검사하는 유일한 test는 cause 생성자가 없는 타입을 본다 유일 호출처의 cause는 Reactor TimeoutException이라 실제 payload 없음
P3 MongoDocumentTooLargeException이 translator 경로에서 -1L, -1L로 생성되며 accessor 계약이 값 부재를 말하지 않음 driver 보고 실패 전체
P3 MongoFailureContext의 canonical constructor가 outcome ↔ ambiguous 정합을 강제하지 않음 production은 classification에서 파생해 일관; 노출은 외부 adopter의 직접 생성
P3 MongoConsistencyDescriptor가 자기 enum javadoc이 "bug"라 부른 secondaryPreferred + majority write 조합을 거부하지 않음 MongoConsistencyRegistry.of(...)는 public; standard()의 6개는 정합
P3/기록 DatabaseProfileName/CollectionProfileName의 검증 코드가 javadoc을 빼면 동일 한쪽만 강화하면 갈라짐
P3/기록 MongoOperationScope.UNSPECIFIED 자리표시자가 정상 profile 이름 값 공간과 겹침 현재 충돌하는 profile 없음

20. Sub-scope 02 완료 조건

  • denominator 70 / 70 FULL_READ (127-...)
  • framework-free 규칙을 ArchUnit과 독립적으로 소스 전수 검색으로 재확인(매치 0)
  • public surface reachability(외부 0 — 설계된 상태이자 한계), invariant sibling 6종 비교, duplicate mechanism 3종, count 기여 59/346 측정
  • 두 확정 finding(§16 P2, §17 P3)은 생성 지점·호출처·test 커버리지를 모두 지목해 근거화(128-...)
  • 이 sub-scope는 소스를 수정하지 않았고 별도 실행 probe도 필요하지 않았다 — 모든 판정이 정적으로 결정 가능하며, hermetic lane 재실행 결과는 sub-scope 01의 126-...이 이미 담고 있다

21. 다음 sub-scope로 넘긴 것

  • MongoConsistencyBinder / ReactiveMongoConsistencyBinder가 descriptor를 실제 driver 설정으로 번역하는 방식과 MongoTemplateSupportContract → sub-scope 4
  • failure package의 classifier·translator·extractor 전체(§15에서 cross-scope 근거로만 읽었다) → sub-scope 9
  • MongoValidatorApplyPolicyMongoServerVersion을 쓰는 방식과 schema/index manifest → sub-scope 7
  • mapping/type/PolicyAwareMongoTypeMapperMongoTypeRepresentationManifest를 강제하는 실제 경로 → sub-scope 3

22. Sub-scope 03 범위와 denominator

내부 상태: COMPLETE — 27 / 27 FULL_READ 범위: mapping/** 13 + nativecap/** 5 + geo/** 5 (production 23, 1,502 LOC) + 전용 test 4 역할: api가 고정한 BSON 표현 manifest를 Spring Data 변환기에 실제로 강제하고, D3 native capability와 geospatial 경계를 정의한다

manifest와 probe: evidence/raw/130-mongo-mapping-nativecap-geo-manifest-and-probes.txt.

세 package의 배선 상태가 서로 다르다. 이것이 이 sub-scope를 읽는 축이다.

package production 배선
mapping MongoPlatformAutoConfiguration:48@Import(MongoMappingConfiguration.class)platform이 켜지면 항상 조립된다
geo 자기 package 밖 production 참조 0 — bean도 소비자도 없다
nativecap 자기 package 밖 production 참조 0 — bean도 소비자도 없다

23. Confirmed P1 — shipped default 조합이 첫 write에서 예외를 던진다

세 사실이 겹친다.

  1. MongoMappingConfiguration.mongoTypeMetadataRegistry()비어 있는 MongoTypeMetadataRegistry.empty()를 기본 bean으로 등록한다. javadoc: "An empty registry so a deployment with no long-lived collection still starts."
  2. MongoTypeMetadataConfigurer.afterPropertiesSet()PolicyAwareMongoTypeMapper모든 MappingMongoConverter에 무조건 설치한다(converters.forEach(converter -> converter.setTypeMapper(typeMapper))).
  3. PolicyAwareMongoTypeMapper.writeType(...)은 등록되지 않은 타입에 대해 IllegalStateException을 던진다 — "no type metadata policy is registered for …; a stored document's type metadata outlives the class, so the policy is a decision to record rather than to default".

즉 module을 켜기만 하고 type metadata를 등록하지 않은 배포는 시작은 하고 첫 write에서 실패한다.

실행 probe

evidence/raw/129-mongo-empty-type-registry-write-probe.txt / 129a-...java. 실제 MappingMongoConverter에 shipped default 조합(빈 registry + policy-aware mapper)을 설치하고 평범한 document를 썼다.

emptyRegistry.rootWrite=IllegalStateException: no type metadata policy is registered for …$ProbeDocument; …
emptyRegistry.nestedWrite=IllegalStateException: no type metadata policy is registered for …$ProbeDocument; …
springDefault.rootWrite=written keys=[_id, value, _class]

같은 converter에 Spring 기본 type mapper를 두면 같은 write가 성공한다. 즉 실패는 문서·엔티티 형태가 아니라 이 leaf가 설치한 mapper에서 온다.

같은 컴포넌트가 같은 질문에 세 가지로 답한다

probe는 그 불일치도 함께 측정했다.

emptyRegistry.policyFor=CLASS_METADATA_ALLOWED
emptyRegistry.writeTypeRestrictions={"_class": {"$in": ["…$ProbeDocument"]}}
emptyRegistry.writeType=IllegalStateException
물음 근거
미등록 타입의 정책은? CLASS_METADATA_ALLOWED MongoTypeMetadataRegistry.policyFor (javadoc: "unregistered types keep Spring Data's default")
미등록 타입으로 type-restricted query를 만들면? Java class name을 _class predicate에 씀 PolicyAwareMongoTypeMapper:134 orElse(CLASS_METADATA_ALLOWED)
미등록 타입을 write하면? 예외 같은 클래스 :75 orElseThrow(...)

읽기 경로와 쓰기 경로가 같은 정책 질문에 정반대로 답하고, 그중 어느 쪽도 registry가 스스로 문서화한 기본값과 일치하지 않는다.

왜 지금까지 드러나지 않았나

이 leaf는 가짜 도메인을 두지 않으므로 저장소 안에 document type이 하나도 없고, 따라서 이 경로를 밟는 저장소 내부 코드가 없다. 그리고 PolicyAwareMongoTypeMapperTest는 mapper를 항상 채워진 registry(fromAnnotations(List.of(LongLivedOrder, ShortLivedAudit)))로 만든다 — shipped default인 빈 registry로 writeType을 부르는 test는 없다.

판정: P1 conditional-production. 저장소 안에서는 재현되지 않지만, README가 서술한 정상 사용법(enabled=true + fork가 자기 document를 추가)을 그대로 따르면 첫 write에서 반드시 발생한다. 수정 방향은 둘 중 하나이고 어느 쪽이든 세 답을 하나로 만들어야 한다 — writeTypepolicyFor처럼 CLASS_METADATA_ALLOWED로 떨어뜨리거나(레거시 허용), 기본 bean을 "미등록이면 실패"가 아니라 "등록을 요구하는 명시적 opt-in"으로 바꾸거나. regression은 빈 registry로 MappingMongoConverter.write(...)를 부르는 한 줄이면 된다.

24. mapping의 나머지는 manifest를 실제로 강제한다

P1과 별개로, 이 package의 나머지는 api manifest를 말이 아니라 코드로 만든다.

  • MongoCustomConversionsFactory.converters(...)가 변환기를 명시적 List 순서로 조립한다. 이유가 주석에 있다 — Spring의 conversion service는 첫 매칭 변환기를 쓰므로 Set이나 classpath 스캔에서 조립하면 JVM 실행마다 다른 변환기가 선택될 수 있다. fingerprint(manifest)가 manifest fingerprint에 변환기 클래스 이름을 이어 붙여 golden BSON snapshot이 비교할 identity를 만든다.
  • 같은 factory가 requireEveryAxisImplemented(...)LOCAL_DATE_TIME_WITH_REGISTERED_CONVERTER를 startup에서 거부한다. enum 상수 자신이 "selecting this without registering the named converter is a startup failure"라고 적어 둔 규칙을 실제로 집행하는 지점이다.
  • BigIntegerRepresentationConverters.forRepresentation(...)은 manifest의 BigInteger 축을 세 변환기 쌍으로 컴파일한다. 주석이 과거 상태를 기록한다 — 이 축은 선언만 있고 컴파일되지 않아 STRINGDECIMAL128이 동일한 document를 만들었고, 하나는 사전식으로 다른 하나는 수치로 정렬된다.
  • LocalDateTimeMappingGuardMongoMappingConfiguration실제 등록된 변환기로 만든다. javadoc이 이전 결함을 적는다 — guard를 withoutConverters()로 만들고 manifest를 검증하게 해서, 명명된 변환기를 등록한 배포와 등록하지 않은 배포를 똑같이 거부했다.
  • BigDecimalToDecimal128Converter는 driver 호출 전에 34 유효숫자·지수 범위를 검사한다. Decimal128은 초과 정밀도를 조용히 반올림하므로, 검사가 없으면 금액이 다른 값으로 저장되고 아무 오류도 나지 않는다.

PolicyAwareMongoTypeMapper의 alias 규칙도 견고하다. alias에 점을 금지하고, 읽을 때 점의 유무로 "legacy class name"과 "alias"를 구분한다 — 그래서 미등록 alias가 class loading으로 fallback해 저장된 문자열이 어떤 클래스를 인스턴스화할지 결정하는 일이 없다. readType(source, basicType)은 저장된 타입이 caller의 기대 타입과 호환되지 않으면 조용히 caller 타입으로 읽지 않고 schema 오류를 던진다.

25. Confirmed P2 — D3 gateway가 문서화한 검사 순서에 존재하지 않는 단계가 있다

PolicyAwareMongoNativeGateway의 javadoc은 이렇게 쓴다.

Runs the design's stated sequence and stops at the first refusal: registration, capability, database profile, collection profile, timeout, category, then execution.

README는 더 긴 목록을 제시한다.

PolicyAwareMongoNativeGateway가 capability → database profile → collection allowlist → operation name → timeoutconsistencyresult limittraceredaction → command category → D4 차단 순서를 고정한다.

실제로 MongoNativeOperationPolicy.require(...)가 수행하는 거부는 여섯 개다 — 등록 여부, 등록된 capability와 제출된 capability의 일치, capability support level, database profile allowlist, collection profile allowlist, category(ADMIN 차단). gateway 자신은 policy.require(operation) → body 실행 → audit 기록만 한다.

빠진 것 중 두 개는 ApprovedMongoNativeOperation필드로 선언까지 해 둔 값이다.

$ git grep -n 'operation.timeout()\|\.hasBody()' -- src/main
…/nativecap/ApprovedMongoNativeOperation.java:64:  public boolean hasBody() {      ← 정의뿐, 호출자 없음
$ git grep -n 'operation.maxResults()' -- src/main
exit=1

timeout은 생성자에서 음수만 거부하고 어디서도 적용되지 않으며, maxResults는 production에서 한 번도 읽히지 않는다(같은 이름의 maxResults() 호출들은 전부 MongoOperationBudget이라는 다른 타입의 것이다). consistency·result limit·trace·redaction 단계는 코드에 존재하지 않는다.

현재 노출은 없다 — MongoNativeCapabilityGatewayPolicyAwareMongoNativeGateway는 production 참조가 0이고 어떤 configuration도 bean으로 만들지 않는다(§22). 그러나 README는 이 클래스를 "D3는 raw client escape가 아니다"라는 주장의 근거로 제시한다. fork가 이것을 그대로 배선하면 문서가 약속한 11단계 중 6단계만 동작하고, 그 사실은 코드를 읽어야만 드러난다.

판정: P2. 수정은 문서를 실제 검사로 줄이거나(정직), 선언된 timeout/maxResults를 gateway가 실제로 적용하도록 만드는 것이다. 후자를 택하면 hasBody()가 처음으로 호출자를 갖게 된다.

26. geo는 index 전제를 스스로 확인하지만 배선되지 않았다

SpringMongoGeospatialOperations는 dispatch 전에 manifest에서 해당 필드의 2dsphere index를 찾고 없으면 거부한다. 이유가 정확하다 — MongoDB는 index 없는 $near는 거부하지만 $geoWithin은 거부하지 않고 collection scan으로 조용히 성공한다. 두 경우를 같은 시점에 같은 메시지로 실패시키는 것이 이 검사의 목적이다.

MongoGeoPoint는 GeoJSON의 longitude-first 순서를 record component 이름으로 못박고 범위를 검증한다. MongoGeoDistance는 단위를 타입에 넣는다 — spherical 연산자는 미터, legacy 연산자는 radian, Spring Data는 metric을 받으므로 맨 double은 600만 배 틀린 채로도 결과를 돌려준다. toMeters()toSpringDistance()의 두 단위 변환을 직접 검산했고 오류는 없다.

MongoGeoQuery는 최대 거리와 결과 상한(≤500)을 둘 다 필수로 만든다. $near는 collection 전체를 거리순으로 정렬해 스트리밍하므로 거리 경계가 없으면 "가까운 것부터 반환하는 full scan"이 된다.

이 package 역시 production 참조 0이다. geo는 README의 package 지도에 "GeoJSON / 2dsphere"로만 적혀 있고 배선을 주장하지 않으므로, nativecap과 달리 문서와 코드가 어긋나지는 않는다. 기록만 한다.

27. Negative-space probes — sub-scope 03

  • 8.1 reachability: mapping은 platform auto-configuration이 import(배선됨), geo·nativecap은 production 참조 0(미배선). 세 결과 모두 130-... §8.1에 명령·exit code와 함께 있다.
  • 8.2 sibling comparison: 같은 "미등록 타입" 질문에 대한 세 답(§23). 그리고 mapping의 두 guard(LocalDateTimeMappingGuard, requireEveryAxisImplemented)는 startup에서 거부하는 반면 type metadata 정책은 write 시점에 거부한다 — 같은 종류의 계약 위반이 서로 다른 시점에 잡힌다.
  • 8.3 duplicate mechanism: 결과 상한을 뜻하는 maxResults()가 두 타입에 있다 — ApprovedMongoNativeOperation(미사용)과 MongoOperationBudget(query·aggregation·cursor에서 실제 사용). 이름이 같고 하나만 살아 있다.
  • 8.4 documentation drift: §25의 D3 순서. 그 밖에 이 sub-scope 범위에서 새 수치 drift는 없다.

28. Sub-scope 03 findings backlog

우선순위 finding reachability
P1 conditional-production shipped default(빈 type metadata registry + 무조건 설치되는 policy-aware mapper)에서 미등록 타입의 write가 IllegalStateException. 같은 컴포넌트가 미등록 타입에 대해 세 가지로 답한다 platform을 켠 모든 배포의 첫 write; 저장소 안에는 document type이 없어 내부 재현 없음
P2 D3 gateway가 문서화한 검사 순서(javadoc 7단계 / README 11단계) 중 실제 존재하는 것은 6개. 선언된 timeout·maxResults는 production에서 한 번도 읽히지 않음 gateway 자체가 미배선이므로 현재 노출 0
P3/기록 geo package가 완전히 미배선(bean 0, 소비자 0) — 다만 문서가 배선을 주장하지 않아 drift는 아님 fork가 배선할 때 사용
P3/기록 maxResults()라는 같은 이름의 결과 상한이 두 타입에 존재하고 하나만 사용됨 혼동

29. Sub-scope 03 완료 조건

  • denominator 27 / 27 FULL_READ (130-...)
  • reachability·sibling·duplicate·drift 4종 probe 수행
  • P1을 실행 probe로 확정(129-..., 129a-...), 임시 test 삭제 후 git status --short clean
  • geo 단위 변환 2종은 코드로 직접 검산했고 오류 없음을 기록

30. Sub-scope 04 범위와 denominator

내부 상태: COMPLETE — 61 / 61 FULL_READ 범위: imperative/** 34 + reactive/** 13 (production 47, 3,369 LOC) + 전용 test 14 역할: 모든 operation이 통과하는 실행 scope — collection 해석, consistency 바인딩, 관측, 실패 번역, 그리고 atomic/bulk/revision/cursor 경로

manifest와 probe: evidence/raw/131-mongo-execution-paths-manifest-and-probes.txt.

배선 상태(§8.1):

타입 production bean
DefaultMongoImperativeExecutor MongoPlatformAutoConfiguration:114
MongoAtomicOperationsTemplate :148
MongoBulkExecutor :166
DefaultReactiveMongoExecutor :293 (reactive template이 bean일 때)
VersionedMongoUpdater ✗ bean 없음
MongoCursorGuard ✗ bean 없음

31. 실행 scope의 고정된 순서가 이 sub-scope의 중심이다

DefaultMongoImperativeExecutor.executeInternal(...)은 순서를 고정한다 — collection profile 해석 → observation 개시 → consistency 바인딩 → callback 실행 → 실패 번역(최대 한 번) → observation 종료. javadoc이 이유를 적는다: "Fixing it here is what makes the invariants hold for operations nobody has written yet."

세 가지 방어가 눈에 띈다.

  • 이미 번역된 MongoPersistenceException은 그대로 통과시킨다. 재번역하면 bulk partial failure나 guardrail 거절처럼 그것을 던진 계층이 더 잘 아는 category를, driver 코드에서 유도한 일반 category로 덮어쓰게 된다.
  • Spring이 감싼 driver 예외를 unwrap(...)으로 되꺼낸다. Spring의 번역은 error label을 잃는데, label이야말로 replayable transaction과 unknown commit을 가르는 값이다.
  • MongoCompletion.successOutcomeFor(operationType)가 read와 write의 성공 outcome을 나눈다. 과거에는 두 executor 모두 성공을 WRITE_CONFIRMED로 기록해, "write가 acknowledge되고 있는가"를 답하는 지표가 read 트래픽의 함수가 됐다. default 분기가 READ_CONFIRMED로 떨어지는 것도 의도적이다 — "the honest answer is the one that claims least".

MongoCollectionProfileRegistry가 "동적 collection 이름 금지"를 강제 가능하게 만드는 지점이다. 애플리케이션은 profile을 부르고 물리 이름은 이 registry만 안다. ScopedAccess.collection(String)은 요청된 collection이 scope의 것과 다르면 거부하고, ScopedMongoOperations의 어떤 메서드도 collection 인자를 받지 않으므로 그 검사를 우회할 방법이 없다.

MongoConsistencyBinder는 profile마다 파생 template을 생성 시점에 한 번 만든다. MongoTemplate.setWriteConcern은 애플리케이션이 공유하는 bean을 변형하므로, 호출마다 설정했다면 다른 스레드의 durability를 바꿨을 것이다. 파생은 Spring Data의 public setter로 원본의 contract(entity callback, auditing, event publisher, write-concern resolver, write-result checking)를 옮긴다 — javadoc이 과거 결함을 기록한다: bare new MongoTemplate(factory, converter)로 파생해 같은 entity가 platform executor 경로와 repository 경로에서 서로 다른 document가 됐다.

32. Confirmed P2 — 서버 측 deadline이 경로마다 다르게 적용되고, 문서가 지목한 메커니즘은 production 호출자가 0이다

BoundScopedOperations의 javadoc은 이 클래스의 존재 이유를 명확히 쓴다.

Every query-shaped method also carries the operation's deadline as maxTimeMS, and that is the difference between a deadline and a report about one. The blocking executor could only measure elapsed time after the callback returned … so an operation that ran past its budget was detected, never stopped. Sent to the server, the same number ends the work.

측정 결과 이 메커니즘은 MongoPlatformCollectionAccess.scoped()를 통해서만 도달하고, production에서 scoped()를 부르는 곳은 0개다(131-... §8.2). 반면 platform이 소유한 세 executor는 전부 rawOperations()를 쓴다 — MongoAtomicOperationsTemplate(2곳), MongoBulkExecutor(1곳), SpringMongoGeospatialOperations(2곳). rawOperations()는 경계 없는 MongoOperations를 그대로 돌려준다.

서버 측 deadline을 실제로 붙이는 다른 경로들은 다른 어휘를 쓴다.

경로 서버에 보내는 deadline
aggregation (PolicyAwareMongoAggregationExecutor:96,106) Math.min(registered.maxTimeMillis(), contextMillis) — 둘을 조정
query builder (PolicyAwareMongoQueryBuilder:200) budget.maxTimeMillis() 단독
reactive cursor (MongoReactiveCursorPublisher:58) budget.maxTimeMillis() 단독
atomic / bulk / geospatial 없음
caller callback via scoped() context.timeout() — production 호출자 0

MongoOperationContext.timeout(모든 operation이 반드시 선언하는 값)이 서버에 도달하는 경로는 aggregation 하나뿐이고, 그것도 budget과의 최소값으로만 도달한다. atomic·bulk·geospatial에서는 executor의 사후 elapsed 검사만 남는데, 그 검사의 주석 자신이 "detected, never stopped"라고 인정한다.

판정: P2. 데이터 손상은 아니지만 platform이 스스로 선언한 자원 경계가 자신의 세 실행 경로에서 서버에 도달하지 않는다. 수정은 MongoPlatformCollectionAccessrawOperations() 대신 deadline이 붙은 접근자를 내보내거나, 세 executor가 query를 만들 때 context.timeout()을 붙이는 것이다.

33. P3 — timeout 초과 경로가 한 observation에 success와 failure를 모두 기록한다

같은 executor의 elapsed 검사 분기는 이렇게 쓰여 있다.

if (elapsed.compareTo(context.timeout()) > 0) {
  observation.success(outcome);
  throw MongoOperationRejectedException.of(...);
}

MongoOperationRejectedExceptionMongoPersistenceException의 하위 타입이고, 이 throw는 같은 try 블록 안에 있으므로 바로 다음 catch (MongoPersistenceException alreadyTranslated)가 잡아 observation.failure(...)를 호출한 뒤 다시 던진다. 결과적으로 하나의 observation에 successfailure가 차례로 호출된다.

shipped 구현에서는 무해하다. MicrometerMongoOperationObserver의 observation은 success/failureoutcomeTags 필드를 덮어쓸 뿐이고 timer는 close()에서 한 번만 정지하므로, 마지막 호출인 failure의 tag로 한 번 기록된다. 문제는 계약이다 — MongoOperationObservation 인터페이스는 둘 중 하나만 호출해야 한다거나 마지막 호출이 이긴다는 규칙을 말하지 않는다. 두 호출을 각각 계수하는 구현을 fork가 만들면 이 경로의 operation이 두 번 계수된다. P3.

34. atomic / bulk / revision — 닫힌 우회로들

이 세 package는 과거에 열려 있던 우회로를 닫은 기록을 코드에 남긴다.

  • bulk가 atomic의 정책을 우회하던 문제. MongoBulkExecutor의 생성자 javadoc이 기록한다 — 단일 문서 경로는 filter/update를 collection 정책에 대조했고 bulk 경로는 정책을 보지 않았으며, 정책은 기본값 없음인 선택적 생성자 인자였다. 같은 update를 배치에 넣으면 보호 필드와 미등록 연산자에 도달할 수 있었다. 지금은 생성자가 하나뿐이고 배치 전체를 dispatch 전에 검증한다("an ordered batch that fails halfway leaves the earlier items applied").
  • bulk 실패에서 per-item 정보를 잃던 문제. catch (MongoBulkWriteException)는 Spring Data가 감싼 실패를 놓쳤고, caller에게는 per-item index 없는 일반 오류 하나가 갔다 — 이 result 타입이 존재하는 바로 그 이유가 사라진 셈이다. 지금은 RuntimeException을 잡고 SpringDataBulkFailureExtractor로 안쪽의 driver 실패를 찾는다.
  • unacknowledged bulk 결과. wasAcknowledged()가 false면 성공 0으로 보고하지 않고 MongoBulkResult.unknown(...)을 돌려준다. 주석: "Reporting zero successes would be a claim, and re-sending on that claim duplicates whatever did apply."
  • revision 재시도. VersionedMongoUpdater.applyWithRetry는 시도마다 문서를 다시 읽고 caller의 계산을 다시 실행한다. 이전에 계산된 update를 재전송하는 재시도는 stale state에서 유도된 값을 쓰는 것이고, 그것이 revision predicate가 막으려던 lost update가 재시도 경로로 되돌아오는 형태다.

두 개의 빈 registry 기본값이 서로 다른 실패 모양을 갖는다(§8.3). MongoAtomicPolicyRegistry.empty()MongoPlatformAutoConfiguration이 기본 bean으로 등록하고, javadoc이 "empty means every atomic and bulk operation is refused rather than permitted"라고 명시하며, 실제 거부도 platform 어휘인 MongoOperationRejectedException이다. 같은 configuration이 등록하는 MongoTypeMetadataRegistry.empty()는 §23에서 본 대로 Spring Data converter 깊은 곳에서 IllegalStateException으로 실패하고, 그 사실은 어디에도 적혀 있지 않다. 같은 설계 의도(미등록은 거부)가 한쪽에서는 문서화된 fail-closed로, 다른 쪽에서는 문서화되지 않은 런타임 예외로 나타난다.

35. reactive 경로가 명시적으로 배치한 세 가지

DefaultReactiveMongoExecutor의 javadoc이 blocking 경로가 공짜로 얻는 것과 여기서 직접 배치해야 하는 것을 대비한다.

  • observation scope를 Reactor 자원(Mono.using/Flux.using)으로 두어 완료·오류·취소 모두에서 닫는다. HTTP 클라이언트 연결 해제가 취소를 일으키므로 취소가 흔한 경우다.
  • timeout을 조립된 publisher에 적용한다. 구독 전에 적용하면 "람다를 만드는 데 걸린 시간"을 재게 된다.
  • context를 Reactor Context로 옮긴다(ReactiveMongoContextKeys). 체인은 operator 경계마다 스레드를 바꾸므로 구독 시점의 ThreadLocal은 driver 응답 시점에 이미 없다.

기록해 둘 관측 하나: executeMany(...)는 성공을 doOnComplete로 기록하므로 취소된 stream은 success도 failure도 기록하지 않는다. observation은 close()되고 초기 tag(result=unknown, failureCategory=none)로 한 번 계수된다. 취소가 흔한 경로라는 점을 감안하면 이는 의도된 분류로 보이지만, result=unknown bucket이 "취소"와 "관측 시작 직후 예외"를 함께 담는다는 사실은 계약에 없다. P3/기록.

36. Negative-space probes — sub-scope 04

  • 8.1 reachability: 6개 주요 타입 중 4개가 bean, VersionedMongoUpdater·MongoCursorGuard는 미배선(fork 공급).
  • 8.2 deadline: §32. scoped() production 호출자 0, rawOperations() 5곳, maxTime 계열 6곳이 세 어휘로 갈림.
  • 8.2b observation: §33.
  • 8.3 duplicate/sibling: 두 빈 registry 기본값의 실패 모양 차이(§34). 그리고 atomic·bulk가 같은 MongoAtomicPolicyRegistry를 공유하도록 강제된 것은 닫힌 우회로의 증거로 기록.
  • 8.4 drift: 이 sub-scope 범위에서 새 수치 drift 없음.

37. Sub-scope 04 findings backlog

우선순위 finding reachability
P2 context.timeout()이 서버에 도달하는 경로가 aggregation 하나뿐. atomic·bulk·geospatial은 rawOperations()로 deadline 없이 실행되고, 이를 위해 만들어진 BoundScopedOperations는 production 호출자가 0 platform이 소유한 세 실행 경로 전부
P3 timeout 초과 분기가 한 observation에 successfailure를 연달아 호출. 인터페이스는 어느 쪽이 이기는지 말하지 않으며 shipped observer만 마지막 호출로 해소 모든 timeout 초과 operation
P3/기록 취소된 reactive stream이 result=unknown bucket에 들어가며 그 사실이 계약에 없음 취소가 흔한 reactive 경로
P3/기록 같은 configuration이 등록하는 두 빈 registry 기본값의 실패 모양이 다르다(atomic=문서화된 platform 거부, type metadata=문서화되지 않은 IllegalStateException) §23의 P1과 같은 뿌리

38. Sub-scope 04 완료 조건

  • denominator 61 / 61 FULL_READ (131-...)
  • reachability·deadline·observation·sibling 4종 probe 수행, 모든 명령과 exit code 보존
  • P2는 scoped()/rawOperations()/maxTime 세 검색의 교차로 확정했고 실행 probe 없이 정적으로 결정 가능
  • 소스 미변경, git status --short clean 유지

39. Sub-scope 05 범위와 denominator

내부 상태: COMPLETE — 29 / 29 FULL_READ 범위: query/** 17 + aggregation/** 5 (production 22, 2,082 LOC) + 전용 test 7 역할: 동적 query를 allowlist로 표현 가능하게 만들고, budget·keyset pagination·aggregation stage 정책을 고정한다

manifest와 probe: evidence/raw/132-mongo-query-aggregation-manifest-and-probes.txt.

40. 이 sub-scope의 설계는 "표현 가능한 query 집합 = 검토된 집합"이다

MongoQueryPolicyPolicyAwareMongoQueryBuilder가 이 leaf에서 가장 직접적인 보안 장치다. builder는 caller가 준 BSON/JSON을 파싱하지 않는다. 모든 predicate는 등록된 field path와 등록된 operator를 지목하고, 그 둘이 policy에 없으면 로컬에서 거부된다 — 그래서 NoSQL operator injection이 검증 문제가 아니라 표현 불가능성이 된다. denylist가 아니라 allowlist인 이유도 적혀 있다: "A denylist has to anticipate the next operator MongoDB adds; an allowlist does not."

세부도 촘촘하다.

  • requireSortable은 등록된 필드라도 sortable이 아니면 거부한다 — 인덱스 없는 sort는 메모리에서 수행되고 sort buffer를 넘기면 실패하기 때문이다.
  • requireSkipWithinThreshold는 deep skip(기본 1000 초과)을 keyset pagination으로 밀어낸다.
  • build(budget)가 유일한 종료 지점이고, 거기서 limit / maxTimeMsec / cursorBatchSize가 반드시 붙는다 — "a query without a result limit and a maxTimeMS is a query with no upper bound on what it can consume".
  • regex는 세 갈래로 나뉜다. whereStartsWith/whereContains는 caller의 텍스트를 Pattern.quote로 escape해 문법을 기여할 수 없게 만들고, 전자는 anchored(인덱스 사용 가능), 후자는 unanchored(scan)로 비용이 호출 지점에 드러난다. whereMatches만 문법을 받는다.

MongoRegexPolicy의 정직함은 기록해 둘 만하다. javadoc이 nested-quantifier 검사가 안전 증명이 아니라 필터라고 명시하고, alternation·?·back-reference로 생기는 catastrophic backtracking을 보지 못한다고 스스로 적는다. 이런 자기 한정은 이 저장소 전체에서 드물지 않지만, 보안 경계에서 특히 유용하다.

MongoKeysetCursorCodec도 마찬가지로 촘촘하다. cursor는 클라이언트를 왕복하는 attacker-controlled 입력이므로 HMAC-SHA256으로 서명하고 상수시간 비교로 검증하며, 실패 메시지를 하나로 통일해 오류로부터 키나 형식을 배우지 못하게 한다. 값은 타입 태그 + 길이 프레이밍으로 인코딩된다 — 과거에는 toString()으로 렌더링하고 String으로 복원해서, Instant/ObjectId/UUID/숫자가 텍스트로 비교되어 다음 페이지가 비거나 행을 건너뛰거나 반복했고 아무 오류도 나지 않았다. 구분자 대신 길이 프레이밍인 이유도 같다: "a delimiter chosen from an alphabet a value can contain is not a delimiter".

MongoKeysetQueryBuilder.resumeCriteria는 사전식 "strictly after"를 전개해서 쓴다. javadoc이 흔한 축약형(a <= A AND _id < I)이 왜 틀리는지 적는다 — a가 더 작고 _id가 더 큰 행을 전부 잃고, 그 증상은 목록 중간에 행이 사라지는 형태라 production에서 오래 살아남는다.

41. Confirmed — 이 sub-scope는 정책과 값 객체이고, 배선된 것은 하나뿐이다

auto-configuration이 이 sub-scope에서 만드는 bean은 MongoBudgetEnforcer 하나다(132-... §8.1). MongoQueryPolicy·PolicyAwareMongoQueryBuilder·MongoRegexPolicy·MongoBudgetPolicyRegistry·MongoKeysetCursorCodec·PolicyAwareMongoAggregationExecutor는 bean도 아니고 main 안에 소비자도 없다(§8.1 세 번째 검색 exit=1).

그 하나조차 짝이 없다. MongoBudgetEnforcer의 유일한 production 소비자는 PolicyAwareMongoAggregationExecutor인데 그것이 미배선이므로, 배선된 enforcer는 현재 아무도 호출하지 않는다. MongoKeysetCursorCodec은 32바이트 이상 서명 키를 요구하는데 그 키를 공급하는 production 코드가 없다 — 생성자 호출은 test 3곳뿐이다.

이것 자체는 결함이 아니다. 이 leaf는 가짜 도메인을 두지 않고 collection profile·field descriptor·budget을 fork가 선언하도록 설계돼 있으며, CLAUDE.md가 "Real forks add their own document, repository, mapper"라고 명시한다. 기록하는 이유는 두 가지다. (a) README의 D1/D2 표는 "typed query, mapping manifest, atomic update, optimistic revision"을 노출 계층의 내용으로 제시하는데, 그중 typed query 계열은 배선 없이 fork가 조립해야 한다는 사실이 그 표에 없다. (b) §41의 다음 항목이 그 조립 시점에만 문제가 된다.

42. P2 — collection 이름 불변식이 aggregation executor의 서명에서 깨진다

MongoCollectionProfileRegistry의 javadoc은 이 leaf의 가장 강한 주장 중 하나를 편다.

A collection name assembled from a request value therefore cannot reach the driver, because there is no path from a string to a collection that does not pass through here.

PolicyAwareMongoAggregationExecutor.execute(...)의 서명은 그 경로다.

public <T> List<T> execute(
    MongoOperationContext context,
    MongoAggregationProfile profile,
    MongoAggregationPlan plan,
    String collection,          // ← registry를 거치지 않는다
    Class<T> outputType)

AggregationResults<T> results = operations.aggregate(aggregation, collection, outputType);

contextcollectionProfile을 이미 들고 있는데도 collection은 별도 String 인자로 받고, 그 값이 그대로 MongoOperations.aggregate(...)에 간다. 같은 클래스가 MongoOperations직접 주입받으므로 imperative 실행 scope도 통과하지 않는다 — collection profile 해석, observation, 실패 번역이 모두 없다(132-... §8.2b: 이 클래스에 observer·observation·translator 참조 0).

현재 노출은 없다(§41: 미배선). 그러나 fork가 이 executor를 배선하는 순간 두 가지가 동시에 생긴다 — registry가 보장한다고 적힌 불변식의 예외 하나, 그리고 관측·실패번역 없이 도는 실행 경로 하나. 판정: P2. 수정은 서명에서 String collection을 없애고 context.collectionProfile()을 registry로 해석하는 것, 그리고 실행을 DefaultMongoImperativeExecutor.executeInternal(...) 안으로 옮기는 것이다. 후자는 §32에서 본 deadline 문제도 함께 해결한다(현재 aggregation은 maxTime을 스스로 붙이므로 그 부분만은 이미 옳다).

43. P3 — MongoRegexPolicy.forbidden()은 금지하지 않는다

public static MongoRegexPolicy forbidden() {
  return new MongoRegexPolicy(1, Set.of(), true);
}

"금지"가 별도 상태가 아니라 최대 길이 1로 표현돼 있다. validate(pattern, flags)의 네 검사를 길이 1짜리 패턴 ^에 대해 따라가면 — 길이 1 ≤ 1 통과, flags 없음 통과, requireAnchored && startsWith("^") 통과, hasNestedQuantifier("^")는 그룹이 없으므로 false 통과 — 수용된다. 그리고 ^는 모든 문자열에 매치된다.

prefixPattern/containsPattern은 escape 결과가 항상 5자 이상이라 길이에서 걸리므로, 이 정책 아래서는 오히려 안전한 두 helper만 막히고 whereMatches(path, "^", "")는 통과한다. 도달하려면 해당 필드가 MongoOperator.REGEX를 등록해야 하므로 조합이 필요하지만, "regex를 금지했다"고 선언한 collection이 모든 문서에 매치되는 패턴을 받는 상태는 정책 이름이 약속하는 것과 다르다. P3. 수정은 policy에 명시적 "regex 불허" 상태를 두고 validate가 그것을 먼저 보게 하는 것이다.

44. Negative-space probes — sub-scope 05

  • 8.1 reachability: 배선된 bean은 MongoBudgetEnforcer 하나. 나머지 전부 미배선이고 그 하나의 소비자도 미배선(§41).
  • 8.2 collection 불변식: §42. registry javadoc의 주장과 aggregation executor 서명의 대조.
  • 8.2b 실행 scope 이탈: aggregation은 MongoOperations를 직접 받아 observation/translator 없이 실행.
  • 8.3 regex 정책: §43.
  • 8.4 서명 키 출처: MongoKeysetCursorCodec의 32바이트 키를 공급하는 production 코드 0 — cursor 서명은 fork가 키를 배선해야 성립한다.

45. Sub-scope 05 findings backlog

우선순위 finding reachability
P2 PolicyAwareMongoAggregationExecutor가 collection을 String으로 받아 registry를 우회하고, MongoOperations를 직접 받아 실행 scope(관측·실패번역)도 우회한다. registry javadoc은 그런 경로가 없다고 적는다 현재 미배선; fork가 배선하는 순간 발생
P3 MongoRegexPolicy.forbidden()이 길이 1 정책이라 ^(모든 문자열 매치)를 수용한다 필드가 REGEX operator를 등록한 경우
P3/기록 query·aggregation·keyset 전부 미배선이고 배선된 MongoBudgetEnforcer는 소비자가 없다. README D1/D2 표는 typed query를 노출 계층 내용으로 제시하나 조립이 fork 몫이라는 사실은 적지 않는다 문서/조립
P3/기록 keyset cursor 서명 키를 공급하는 production 경로 없음(생성자 호출은 test 3곳) fork 배선 시점

46. Sub-scope 05 완료 조건

  • denominator 29 / 29 FULL_READ (132-...)
  • reachability·불변식·실행 scope·regex 정책·키 출처 5종 probe 수행
  • 두 finding 모두 정적으로 결정 가능하여 실행 probe 불필요, 소스 미변경

47. Sub-scope 06 범위와 denominator

내부 상태: COMPLETE — 27 / 27 FULL_READ 범위: transaction/** 20 (production, 1,617 LOC) + 전용 test 7 역할: body 재시도와 commit 재시도를 서로 다른 루프로 유지하는 것 — 이 leaf에서 가장 결과가 무거운 규칙

manifest와 probe: evidence/raw/133-mongo-transaction-manifest-and-probes.txt.

48. 설계의 중심 규칙이 실제로 구현돼 있다

MongoTransactionRetryCoordinator의 javadoc이 규칙과 그 대가를 함께 적는다.

TransientTransactionError means nothing was committed, so the body may run again — from a new session… UnknownTransactionCommitResult means the commit may already have succeeded, so the body must not run again… Getting this wrong does not fail loudly. It produces a second order, a double refund, or a duplicate ledger entry — during a failover, when nobody is reading the logs.

구현은 그 규칙을 구조로 만든다.

  • 두 루프. execute(...)의 바깥 루프는 MongoTransactionTransientException에서만 continue하고, 매 시도마다 sessions.open(profile)새 세션을 연다. commitWithRetry(...)는 이미 계산된 value를 인자로 받아 그대로 반환하며, javadoc이 "nothing here may recompute it, because recomputing is indistinguishable from replaying"라고 못박는다.
  • Spring의 transaction 추상화를 쓰지 않는다. SpringMongoTransactionSessionFactory가 이유를 적는다 — MongoTransactionManagerTransactionTemplate은 callback이 반환되면 암묵적으로 commit하므로 body와 commit을 한 단계로 접는데, 설계 전체가 그 둘이 다르게 실패하고 다르게 재시도된다는 데 서 있다.
  • 분류는 label이 살아 있는 경계에서 한다. driver 실패는 session factory 안에서 분류되고, 위층 coordinator는 platform의 두 transaction 예외만 본다. classify(...)Throwable을 받는다 — Spring Data가 감싼 실패는 같은 label과 server code를 갖지만 다른 타입으로 도착해 분류를 통째로 건너뛰었고, 그래서 transient 오류가 terminal로 처리돼 재시도되지 않았다.
  • context를 scope에서 유도한다. commit-unknown context를 먼저 만들고 classifier가 고른 예외로 감싸는 대신, scope가 COMMIT_ONLY면 commit-unknown context를, WHOLE_TRANSACTION이면 transient context를 만든다(§15의 두 예외 생성자 불변식과 맞물린다).
  • reactive도 같은 규칙. SpringReactiveMongoTransactionExecutor는 body 재시도에서 caller의 publisher를 재구독하고 commit 재시도에서는 commit()만 재구독한다 — "re-subscribing a publisher is exactly how a reactive codebase replays work that may already have been committed". 정리(cleanup)도 phase-aware다: commit-unknown이면 abort()하지 않고 release()만 한다.

주변 결함 이력도 촘촘히 기록돼 있다.

  • startTransaction() 실패 시 세션을 닫지 않아 시도마다 pool 항목이 샜다 → 이제 실패 경로에서 close하고 close 실패는 원인에 suppressed로 붙인다.
  • MongoTransactionScope.bindset/remove였다 → 중첩 시 안쪽 remove가 바깥 body의 바인딩을 지워, 이후 require()가 실패하거나 평범한 template으로 fallback한 코드가 transaction 밖에 썼다. 지금은 이전 값을 복원한다.
  • reactive executor가 budget 검사에 Duration.ZERO.plusNanos(1)을 넘겨 maxElapsed가 영원히 도달 불가였다 → 이제 주입 가능한 LongSupplier nanoTime으로 실제 경과를 잰다.
  • delayBefore를 두 번 호출해 metric에 기록된 지연과 실제로 기다린 지연이 달랐다 → 한 번 계산해 재사용.
  • MongoRetryBudget.allowsAttempt가 첫 시도에도 elapsed < maxElapsed를 요구해, none()(maxElapsed=0)이 body 자체를 거부했다 → 첫 시도는 재시도가 아니므로 무조건 허용.

MongoTransactionProfile은 secondary read profile을 생성자에서 거부하고 timeout이 서버의 transactionLifetimeLimitSeconds(기본 60초)를 넘지 못하게 한다.

49. Confirmed P2 — 이 subsystem 전체가 배선돼 있지 않은데, 그것을 켜는 flag는 startup 검사를 수행한다

MongoPlatformAutoConfiguration에서 Transaction/CausalSession/RetryCoordinator를 찾으면 매치 0이다(133-... §8.1, exit=1). transaction package 밖의 production 참조도 0이다. 즉 MongoTransactionExecutor·MongoTransactionRetryCoordinator·SpringMongoTransactionSessionFactory·causal session executor 어느 것도 bean이 아니고, 이 leaf의 다른 production 코드가 부르지도 않는다.

그런데 MongoPlatformSettings.transactions는 살아 있는 flag다. §6의 probe에서 platform.transactions=true가 그대로 bound되는 것을 확인했고, MongoPlatformAutoConfiguration:362가 그 값을 MongoStartupValidator에 넘기며, validator는 transactionsEnabled && !capabilities.isStable(TRANSACTION)이면 startup을 거부한다(MongoStartupValidator:97).

결과적으로 ca-skeleton.persistence-mongo.platform.transactions=true를 설정한 배포는 — topology probe와 나머지 startup 입력이 모두 갖춰졌다면 — topology가 transaction을 지원하는지 검증받고, 그 다음 transaction을 실행할 bean은 하나도 받지 못한다. flag는 capability 요구만 만들고 capability를 제공하지 않는다.

이것을 §6의 changeStreams와 나란히 놓으면 대비가 분명하다. change stream은 실행체가 없다는 사실을 인정하고 flag 값을 강제로 false로 만든다(그 방식의 문제는 §6에서 따로 지적했다). transaction은 실행체가 없는데 flag는 살아서 startup 요구를 만든다. 같은 상황에 대해 두 가지 다른 처리가 한 record 안에 있다.

판정: P2. 데이터 위험은 없다 — 없는 것을 쓸 수는 없다. 위험은 운영자의 기대다. 수정은 셋 중 하나다: transaction executor를 조건부 bean으로 조립하거나, flag가 무엇을 켜는지(=startup 검증만) 문서에 적거나, changeStreams처럼 명시적으로 거부하거나. 셋 중 어느 것도 지금은 되어 있지 않다.

50. Negative-space probes — sub-scope 06

  • 8.1 reachability: 배선 0, cross-package 참조 0(§49).
  • 8.1b flag ↔ 조립 불일치: §49. transactions는 검증만 만들고, changeStreams는 값을 삼키며, 둘 다 실행체가 없다.
  • 8.2 규칙 검증: 두 루프의 분리를 코드 구조로 확인(§48). blocking·reactive 양쪽 모두.
  • 8.3 scope 바인딩: 중첩 bind가 복원 방식인지 확인. 두 개의 ThreadLocal이 존재한다 — MongoTransactionScope.CURRENTSpringMongoCausalSessionExecutor.CURRENT — 서로 독립이고 각자의 require*()를 갖는다. causal session 안에서 transaction scope를 물으면 "no MongoDB transaction is active"가 나오고 그 반대도 마찬가지다. 의도된 분리로 보이나 두 scope가 겹칠 때 어느 쪽 operations를 써야 하는지에 대한 계약은 어디에도 없다. P3/기록.
  • 8.4 profile 경계: 60초 서버 한계와 secondary read 거부 확인.

51. Sub-scope 06 findings backlog

우선순위 finding reachability
P2 transaction subsystem 전체가 미배선(bean 0, cross-package 참조 0)인데 platform.transactions=true는 startup에서 TRANSACTION capability를 요구한다 — 요구만 만들고 제공하지 않는 flag flag를 켠 모든 배포
P3/기록 MongoTransactionScopeSpringMongoCausalSessionExecutor가 각자 독립된 ThreadLocal을 갖고, 두 scope가 중첩될 때 어느 operations가 유효한지에 대한 계약이 없다 fork가 둘을 함께 배선할 때

52. Sub-scope 06 완료 조건

  • denominator 27 / 27 FULL_READ (133-...)
  • reachability·flag 정합·규칙 구조·scope 바인딩·profile 경계 5종 probe 수행
  • 두 재시도 루프의 분리, 세션 수명, 실패 분류 경계를 blocking·reactive 양쪽에서 코드로 추적
  • 소스 미변경

53. Sub-scope 07 범위와 denominator

내부 상태: COMPLETE — 58 / 58 FULL_READ 범위: schema/** 30 + migration/** 19 (production 49, 3,124 LOC) + 전용 test 9 역할: collection의 index·validator·문서 모델을 선언으로 만들고, migration을 lease와 ledger 위에서 한 번만 돌게 한다

manifest와 정적 probe: evidence/raw/134-mongo-schema-migration-manifest-and-probes.txt. 실행 probe: evidence/raw/134a-mongo-schema-migration-execution-probes.txt.

54. 설계의 두 축 — 선언이 진실이고, 적용은 D4다

MongoCollectionManifest의 javadoc이 첫 번째 축을 적는다.

Deliberately not derived from annotations. Spring Data's @Indexed can create an index as a side effect of a class being on the classpath, which means production index state depends on deployment order and on which module happened to be loaded.

그래서 index·validator·문서 모델이 전부 명시적 선언이고, 검증은 집합이 다 모인 뒤에 MongoManifestRegistry에서 일어난다 — collection 이름 중복, 한 collection 안의 index 이름 중복, 문서 모델의 budget 초과는 선언 시점에는 조용하고 비교 시점에만 보이기 때문이다. MongoIndexManifestexpectedUsageAPPLICATION 소유일 때 필수로 요구하는 것도 같은 계열이다: "an index nobody can name a query for cannot be reviewed for removal later".

두 번째 축은 적용 권한이다. MongoIndexApplyPolicy는 APPLY → APPLY_WITH_DIFF → DIFF_WITH_APPROVED_APPLY → REPORT_ONLY 사다리를 두고 production에서 runtime의 index 변경을 금지한다. MongoValidatorApplyPolicy.runtimeMayApply()항상 false다 — validator 변경은 이후 모든 write의 수용 규칙을 다시 쓰므로 D4다. MongoIndexRetirementState는 DEPRECATED → USAGE_OBSERVED → HIDDEN → REGRESSION_CHECKED → APPROVED → DROPPED를 한 칸씩만 전진시키고, successor()를 ordinal이 아니라 switch로 적는 이유까지 남긴다("an ordinal-based successor silently changes meaning the moment someone inserts a constant, and this sequence is a safety procedure").

문서 모델 쪽도 촘촘하다. MongoDocumentSizeBudget은 MongoDB의 16 MiB 한계가 아니라 그 1/4인 4 MiB를 상한으로 강제한다 — "the write that fails is the first symptom". MongoDocumentModelValidator는 위반을 전부 모아서 한 번에 던진다("a modelling review that surfaces one problem per run turns a five-minute fix into five rounds"). EmbeddedCollectionDescriptor.unbounded()거부되기 위해 존재한다 — "우리는 모른다"를 생략이 아니라 기록으로 표현하게 한다. worstCaseDocumentBytes()는 overflow 대신 포화한다("a silent wraparound would turn 'infinitely large' into 'comfortably small'").

MongoValidatorApplyPolicyCERTIFIED_RELEASE_LINES에는 이미 한 번 고쳐진 결함이 주석으로 남아 있다: 과거의 Set.of("7.0","8.0").contains(serverVersion)은 서버가 "8.0.4"를 보고하므로 모든 실제 배포에서 false였다 — "the certified lane was a lane nothing was ever in". 지금은 MongoServerVersion.parse로 major/minor를 비교한다(§18.1에서 본 MongoServerVersion의 유일한 production 소비자가 바로 이 줄이다).

55. migration은 fencing을 정면으로 다룬다

MongoMigrationLock.fence()의 javadoc이 이 sub-scope에서 가장 정확한 문장을 담고 있다.

A lease expiring is not the same as its holder stopping. A runner paused inside a long execute — a stop-the-world pause, a stalled network write — loses the lease on the server while its thread is still alive and still writing… Refreshing more often does not fix that: the first runner is not running at the moment it would refresh.

그래서 lease 위에 monotonic fencing token을 얹고, MongoCollectionMigrationLock.tryAcquire가 그 token을 lease를 부여하는 같은 조건부 update 안에서 서버가 증가시킨다("A token handed out anywhere else could be handed out twice"). held()는 owner 이름이 같아도 fence가 다르면 false를 반환한다 — 프로세스가 재시작했거나 운영자가 owner 문자열을 재사용한 경우다. matchedCount를 쓰는 이유(같은 값을 다시 쓰면 modifiedCount가 0이라 소유권 판정이 뒤집힌다)도 두 곳에 적혀 있다.

MongoMigrationHeartbeat은 이미 고쳐진 결함의 산물이다: runner가 execute반환된 뒤에 한 번만 refresh했으므로, 40분짜리 execute는 35분 동안 만료된 lease를 들고 있었고 그 사이 두 번째 runner가 정당하게 획득해 같은 migration을 동시에 돌렸다. 이제 heartbeat이 migration에게 넘겨진다 — batch 경계를 아는 것은 migration뿐이기 때문이다.

MongoCollectionMigrationLedger.saveCheckpoint에는 두 개의 결함 이력이 주석으로 남아 있다. upsert 하나로는 "매치할 게 없었다"와 "fence filter가 배제했다"를 구분할 수 없어 모든 migration의 첫 checkpoint가 "a newer migration runner owns the lease"로 거부됐고, 동시에 진짜 배제 경로는 unique index의 duplicate-key로 죽어 그 문장을 만드는 분기가 도달 불가였다. 지금은 replace-then-insert로 두 경우를 분리한다.

MongoMigrationrollback이 없는 것도 명시적 결정이다 — "A rollback method implies the reverse operation is always safe and always possible, and for a backfill that dropped a column's old values it is neither." 실패한 production 변경은 forward-fix migration으로 고친다.

mongoMigrationTest lane은 HEAD에서 green이다: 1 class / 8 tests / 0 failures (134a-... §8.4b).

56. P2 — recordApplied는 문서화된 fence 계약을 구현하지 않고, 보호를 역전시킨다

MongoMigrationLedger.recordApplied의 javadoc은 계약을 분명히 적는다.

Records a completed migration, only if the fence is still the current one… A ledger entry from a superseded runner says a migration completed when the work it describes was overwritten by the runner that replaced it. @throws MongoOperationRejectedException when a newer acquisition exists

구현은 그렇지 않다. MongoCollectionMigrationLedger.recordApplied:93requireCurrentFence(fence, …)를 부르는데, 그 메서드가 하는 검사는 fence == UNFENCED인지 하나뿐이다(134-... §8.2). 저장된 fence와의 비교도, 서버측 조건도 없고, fence는 그냥 문서의 한 필드로 들어간다. 이름이 하는 말("current")과 코드가 하는 일("fenced")이 다르다. FlamingockLedgerAdapter.recordApplied는 fence 인자를 아예 무시한다.

실제 서버(MongoDB 8.0 replica set)에서 확인했다(134a-... PROBE A). live runner가 fence 5로 checkpoint o-900을 쓴 상태에서 fence 1을 든 superseded runner가 두 번 쓴다.

PROBE saveCheckpoint(fence=1 over stored 5) -> REFUSED MongoOperationRejectedException
PROBE recordApplied(fence=1 over stored 5)  -> ACCEPTED
PROBE ledger entry now = { migrationId=20260829-001, checksum=superseded,
                           operator=stale-runner, fence=1 }
PROBE recordApplied(live fence=5, after stale wrote) -> REFUSED MongoWriteException:
        E11000 duplicate key error … index: migrationId_1

같은 fence 계약이 saveCheckpoint에서는 지켜지고 recordApplied에서는 지켜지지 않는다. 결과는 단순한 누락이 아니라 역전이다 — 밀려난 runner가 ledger를 차지하고, 실제로 작업한 runner는 platform의 lease 문장 대신 driver의 duplicate-key 예외를 받는다. 그리고 이것은 이 파일이 saveCheckpoint에서 이미 한 번 고친 바로 그 형태다(§55: "a superseded runner got a driver-level duplicate-key error instead of the sentence written for it"). 수정이 한쪽에만 적용됐다.

도달성. 조립된 경로에서는 MongoMigrationRunner.applyOnerecordApplied 직전에 lock.refresh(...)를 부르고, MongoCollectionMigrationLock.refresh는 owner+fence 조건부라 stale이면 던진다. 그래서 기본 조합에서는 인접한 다른 장치가 막아 준다 — 다만 (a) refresh와 insert 사이에 TOCTOU 창이 남고, (b) 그 보호는 MongoCollectionMigrationLock을 쓸 때만 존재하며, (c) MongoMigrationLedger는 fork가 구현하도록 공개된 인터페이스인데 그 인터페이스가 약속하는 보호는 어느 구현에도 없다.

판정: P2. 수정은 saveCheckpoint와 같은 모양이다 — recordApplied도 저장된 fence를 조건으로 삼고, duplicate-key를 잡아 platform 예외로 번역하는 것. 지금은 test도 이 경계를 보지 않는다: MongoMigrationFencingTest.ledgerWritesCarryTheirFence는 fence 값이 전달되는지만 보고, MongoMigrationLaneTest의 superseded 테스트는 checkpoint만 다룬다.

57. P2 — index diff가 실제로 비교하는 것은 두 필드뿐이다

MongoIndexManifest는 14개 요소를 선언한다 — keys, unique, sparse, hidden, deprecated, partialFilterExpression, collationProfile, expireAfter, wildcardProjection, shardKeySupport, expectedUsage, owner, metadataOwnership. MongoIndexDescriptorView는 6개만 나르고, MongoIndexDiffEngine.compare가 실제로 비교하는 것은 keySignatureunique 두 개다(134-... §8.2b, grep 결과 4950행이 전부).

게다가 hidden한 방향으로만 본다: declared.hidden() && !actual.hidden()(55행). 반대 — 서버에서는 숨겨져 있는데 manifest는 보인다고 선언한 index — 에 해당하는 분기가 없다. 그것은 planner가 manifest가 살아 있다고 적은 index를 쓰지 않고 있는 상태이고, 정확히 은퇴 워크플로가 HIDDEN에 세워 둔 index를 다시 살리기로 한 뒤에 생기는 상태다.

hermetic probe로 확인했다(134a-... PROBE B). 선언은 ix_ttl(expireAfter=30일)과 ix_active(sparse + partialFilter + collation, 보임), 서버는 같은 이름·같은 키·같은 uniqueness에 ix_active만 숨겨져 있다.

PROBE diff.isClean() -> true
PROBE diff.render()  -> []   (빈 문자열)

TTL 보존기간 변경, sparse/partialFilter/collation 변경, 그리고 "서버에서 숨겨진 채 선언은 보임"이 전부 drift 없음으로 렌더링된다. 이 중 TTL이 가장 무겁다 — 30일을 1일로 바꾸는 것은 대량 삭제이고, drift 보고서는 그것을 clean이라고 말한다.

MongoIndexDescriptorView의 javadoc이 "reduced to the fields a diff can compare"라고 스스로 한정하는 것은 사실이지만, 그 축소의 결과(무엇이 감지 불가가 되는지)는 어디에도 적혀 있지 않고, MongoIndexDiff.render()가 CI artifact로 쓰이도록 설계돼 있으므로 "빈 보고서 = 일치"로 읽힌다. 판정: P2. 최소 수정은 MongoIndexDescriptorViewexpireAftersparse를 추가하고 compare에서 비교하는 것, 그리고 actual.hidden() && !declared.hidden()에 대한 unhide 항목을 두는 것이다. 그것이 과하다면 최소한 비교 대상 필드 집합을 diff 출력에 함께 적어 "빈 보고서"가 무엇을 뜻하는지 읽는 사람이 알 수 있게 해야 한다.

58. P3 — TTL이 두 곳에 선언되고, 규칙을 가진 쪽은 아무도 쓰지 않는다

TTL을 표현하는 방법이 이 sub-scope 안에 둘 있다.

  1. MongoIndexManifest.expireAfter(Duration) — 검증은 생성자의 isNegative() 하나.
  2. MongoTtlPolicy / MongoTtlIndexDescriptor + MongoTtlPolicyValidator — 세 가지 실질 규칙: 최소 보존기간 1분(그 아래는 한 번의 sweep으로 전체 population을 지운다), expiry 필드의 BSON 타입이 date인지(아니면 MongoDB가 조용히 무시한다), 그리고 읽기가 expiresAt > applicationNow를 거는지(TTL monitor는 임의 간격으로 돌므로 만료된 문서는 그때까지 계속 읽힌다).

둘 사이에 참조가 하나도 없다(134-... §8.3: schema/ttl 밖의 production 참조 검색 exit=1). MongoIndexManifest.isTtlIndex()ttl()은 선언부 말고 호출자가 아예 없다. 그래서 manifest 경로로 선언된 TTL index는 위 세 규칙 중 어느 것도 통과하지 않는다. probe로 확인:

PROBE MongoIndexManifest.expireAfter(1s) built -> PT1S isTtlIndex=true

MongoTtlPolicyValidator.MINIMUM_SAFE_RETENTION이 1분인데, manifest는 1초를 그대로 만든다. 그리고 schema/ttl의 네 타입은 이 leaf의 production 어디에서도 쓰이지 않는다 — 규칙을 가진 표현은 아무도 안 쓰고, 쓰이는 표현은 규칙이 없다. P3. (지금 결함이 아닌 이유는 §59와 같다: manifest를 조립하는 production 코드 자체가 없다. fork가 조립하는 순간 결함이 된다.)

59. P3 — Flamingock lease로는 어떤 migration도 실행할 수 없고, javadoc은 다르게 적는다

FlamingockLockAdapter.fence()UNFENCED(-1)를 반환하고, 그 이유를 정직하게 적는다 — 로컬 카운터로 fencing을 흉내내면 "look like fencing and protect nothing". 여기까지는 옳다. 문제는 그 다음 문장이다.

The runner refuses resumable migrations under an unfenced lease for exactly this reason.

MongoMigrationRunner.apply:82의 검사는 stream보다 앞에 있고 migration의 성질을 보지 않는다. hermetic probe에서 checkpoint를 만들지 않는(=resumable이 아닌) migration을 넣어 확인했다(134a-... PROBE C).

PROBE FlamingockLockAdapter.fence() = -1
PROBE runner.apply(non-resumable migration, Flamingock lease) -> REFUSED
      MongoOperationRejectedException: this migration lease exposes no fencing token …

즉 engine-agnostic 경로 전체 — Mongock을 새 프로젝트에서 채택하지 않겠다는 결정을 되돌릴 수 있게 만들어 둔 그 경계 — 는 MongoMigrationRunner를 통해 아무것도 실행할 수 없다. FlamingockMongoMigrationAdapterTest도 이 조합을 시험하지 않는다(adapter lock으로 apply를 부르는 테스트가 없다). P3. 수정은 둘 중 하나다: javadoc을 실제 동작("every migration")에 맞추거나, unfenced lease에서 non-resumable migration을 허용하도록 검사를 옮기거나. 전자가 정직하고 후자는 별도 판단이 필요하다.

60. Confirmed — 이 sub-scope도 선언 라이브러리이고, ledger의 유일성 장치는 production에서 만들어지지 않는다

auto-configuration이 schema/**·migration/**에서 만드는 bean은 0개다(134-... §8.1: MongoPlatformAutoConfiguration에서 걸리는 것은 api.mapping.MongoTypeRepresentationManifestapi.schema.MongoSchemaVersionRange뿐 — 둘 다 sub-scope 02 소속). 그리고 정책 계층은 소비자조차 없다:

타입 production 소비자
MongoIndexApplyPolicy, requireRuntimeApplyAllowed 0 (test 1곳)
MongoValidatorApplyPolicy 0 (test 2곳)
MongoIndexDiffEngine, MongoValidatorDiffEngine 0 (new는 test에서만)
MongoTtlPolicyValidatorschema/ttl 4종 0
MongoManifestRegistry 1 — geo/SpringMongoGeospatialOperations(그 자체가 미배선, §26)
MongoMetadataOwnership advanced/encryption/qe, advanced/search(sub-scope 10)
MongoMigrationCheckpoint advanced/tenancy/database 2개(sub-scope 10)
MongoMigrationRunner/Ledger/Lock 0

즉 D4 admin plane의 "runtime은 index/validator를 바꿀 수 없다"는 규칙은 현재 runtime이 그 코드를 부르지 않는 방식으로 지켜지고 있다. 사다리는 만들어져 있고 올라서는 사람이 없다.

한 가지는 따로 적어 둘 만하다. MongoCollectionMigrationLedger.ensureIndexes() — javadoc이 "The unique index on the migration id is the part that matters"라고 말하고, 실제로 §56의 duplicate-key도 그 index가 만든 것이다 — 를 부르는 곳은 test 6곳뿐이다(134-... §8.3c). 생성자와 분리한 이유는 명시돼 있다("a ledger that silently creates indexes on first use is the auto-index-creation behaviour the platform refuses everywhere else"). 옳은 결정이지만, 그 결과 ledger의 중복 방지는 fork가 admin plane에서 명시적으로 만들어 줘야 성립하는 전제가 되고, 그 전제는 MongoMigrationRunner나 module README 어디에도 적혀 있지 않다. 만들지 않은 채 운영하면 §56의 경합은 duplicate-key 예외조차 없이 두 개의 ledger 항목으로 끝난다. P3/기록.

61. Negative-space probes — sub-scope 07

  • 8.1 reachability: bean 0, 정책 계층 소비자 0(§60). cross-package 소비자는 geo·advanced 계열뿐이고 그중 geo는 미배선.
  • 8.2 계약 ↔ 구현 대조: recordApplied의 javadoc 계약과 두 구현(§56). 실서버 실행 probe로 확정.
  • 8.2b 비교 필드 집합: 선언 14 vs 관측 6 vs 실제 비교 2(§57). hermetic 실행 probe로 확정.
  • 8.2c 조건부 형제: hidden이 한 방향만 비교됨(§57). saveCheckpoint는 fence 조건부인데 recordApplied는 아님(§56) — 같은 파일 안의 형제 비교.
  • 8.3 중복 메커니즘: TTL 두 표현(§58), ledger 두 구현·lock 두 구현(§56·§59), ensureIndexes 호출자 부재(§60).
  • 8.4 문서/개수 drift: mongoMigrationTest lane은 build.gradle:119에 존재하고 tag는 mongodb-migration, HEAD에서 1 class / 8 tests / 0 failures. module README에는 manifest·runner 언급 없음. docs/superpowers/plans/…-implementation-plan.md는 이 코드를 modules/mongodb/mongodb-migration-core 아래 별도 모듈로 적고 있으나 실제 위치는 단일 leaf 안의 package다(§0의 모듈 배치 drift와 같은 계열).

62. Sub-scope 07 findings backlog

우선순위 finding reachability
P2 MongoMigrationLedger.recordApplied의 javadoc은 fence 조건부 쓰기와 MongoOperationRejectedException을 약속하지만, MongoCollectionMigrationLedgerUNFENCED만 검사하고 FlamingockLedgerAdapter는 fence를 무시한다. 실서버 probe에서 밀려난 runner가 ledger를 차지하고 live runner가 driver duplicate-key를 받는다 runner 경로는 인접한 lock.refresh가 막아 줌(TOCTOU 창 존재); ledger를 직접 쓰거나 다른 lock 구현을 쓰는 fork는 무방비
P2 index diff가 비교하는 것은 keySignature·unique 둘뿐이라 TTL 보존기간·sparse·partialFilter·collation 변경과 "서버에서 숨겨짐 + 선언은 보임"이 전부 clean으로 보고된다 (probe: isClean()=true, render()="") drift 보고서를 CI artifact로 쓰는 모든 배포
P3 TTL이 MongoIndexManifest.expireAfterMongoTtlPolicy 두 곳에 있고 서로 참조가 없다. 규칙(최소 1분·BSON date·읽기 술어)을 가진 쪽은 production 소비자 0, 쓰이는 쪽은 isNegative()만 본다 (probe: 1초 TTL이 그대로 생성됨) fork가 manifest를 조립하는 시점
P3 FlamingockLockAdapter의 javadoc은 runner가 "resumable migrations"만 거부한다고 적지만 실제로는 모든 migration을 거부한다 — engine-agnostic 경로로는 아무것도 실행할 수 없다 (probe로 확인) Flamingock 어댑터를 쓰려는 모든 시점
P3/기록 ensureIndexes()(ledger의 유일성 장치)의 호출자가 test뿐이고, admin plane에서 만들어야 한다는 전제가 문서화돼 있지 않다 운영 배포 시점
P3/기록 schema·migration 전체가 bean 0이고 apply policy·diff engine·TTL validator는 production 소비자 0. D4 규칙이 "runtime이 그 코드를 부르지 않는 방식"으로 지켜지고 있다 문서/조립

63. Sub-scope 07 완료 조건

  • denominator 58 / 58 FULL_READ (134-... OWNED FILES)
  • reachability·계약대조·비교필드집합·조건부형제·중복메커니즘·문서drift 6종 probe 수행
  • 정적으로 결정 불가한 세 지점(recordApplied fence, index diff 사각지대, Flamingock lease)을 실행 probe로 확정(134a-...)
  • 임시 probe class 2개 추가 후 제거, git status --short = 0 (134a-... 말미)

64. Sub-scope 08 범위와 denominator

내부 상태: COMPLETE — 26 / 26 FULL_READ 범위: changestream/** 21 (production, 1,317 LOC) + 전용 test 5 (996 LOC) 역할: at-least-once change stream 소비 — 저장된 위치에서 열고, 순서대로 투영하고, 투영이 성공한 뒤에 위치를 쓴다

manifest와 정적 probe: evidence/raw/135-mongo-changestream-manifest-and-probes.txt. 실행 probe: evidence/raw/135a-mongo-changestream-execution-probes.txt.

65. 이 sub-scope는 이 leaf에서 유일하게 "조립까지 된" 대형 서브시스템이다

앞선 sub-scope들과 다르다. MongoPlatformAutoConfiguration이 두 개의 bean을 실제로 만든다.

  • mongoChangeStreamSource(209행) — SpringReactiveChangeStreamSource, 무조건.
  • reactiveMongoChangeStreamConsumer(235행) — fork만 공급할 수 있는 5종(MongoChangeStreamSubscription, MongoResumeCheckpointStore, MongoResumeTokenCodec, MongoChangeProjector, MongoChangeDeduplicationStore)에 @ConditionalOnBean. pipeline·runner·recovery policy·invalidate recovery는 auto-configuration이 직접 new한다.

즉 fork가 설계가 요구하는 다섯 개를 그대로 제공하면 완성된 소비자가 돈다. 이 사실이 아래 §67의 심각도를 결정한다.

설계 자체는 이 leaf에서 가장 정교한 축에 속한다.

  • 순서가 계약이다. MongoChangeStreamRunner: 투영 먼저, checkpoint 나중. "Checkpointing first would mean a crash between the two loses the event permanently, with no trace." 그래서 중복을 택하고 중복을 제거한다.
  • claim은 3-state다. 과거 alreadyProjected + markProjected(읽고-쓰기)는 동시에 false를 읽은 두 subscriber가 둘 다 투영했다 — "the deduplication that exists precisely because redelivery is guaranteed did not survive concurrency". 지금은 CLAIMED/ALREADY_COMPLETED/BUSY의 원자적 전이다.
  • 빈 완료는 프로토콜 위반이다. Mono<Boolean>이 empty로 완료되면 flatMap을 그냥 통과해 "투영도 checkpoint도 없이 아무도 문제를 보고하지 않는" 상태가 됐다. 이제 switchIfEmpty(Mono.error(...))로 잡는다.
  • identity는 재전달에 안정적이고 documentKey를 감춘다. SHA-256, 구분자는 ASCII unit separator(0x1F) — namespace/clusterTime/operationType에 나타날 수 없으므로 필드 재배열로 다른 이벤트의 identity를 위조할 수 없다. 한 transaction이 같은 문서를 두 번 고치면 앞 네 필드가 모두 같아지므로 txnNumber+lsid discriminator를 추가로 넣는다 — 없으면 두 번째가 첫 번째의 재전달로 버려진다.
  • resume token은 절대 렌더링하지 않는다. MongoResumeCheckpoint.toString()은 길이만 보고한다. token은 clusterTime과 documentKey를 인코딩하므로 로그에 찍는 순간 production write의 모양과 타이밍이 샌다.
  • MongoResumeTokenCodec에는 기본 구현이 없다. "a built-in that merely encoded would be worse than none: it would satisfy the type and none of the reason for it."
  • HISTORY_LOST는 자동 복구하지 않는다. "resuming from now… the projection then looks healthy and is quietly wrong, which is worse than a stopped consumer somebody has to look at."
  • MongoClusterTime은 숫자로 비교한다. 텍스트 비교는 1700000000.101700000000.9보다 앞에 놓는데, 그것은 바쁜 1초가 정확히 만드는 경우다.

66. Confirmed — MongoChangeStreamPipeline은 존재 이유가 명확한 클래스다

javadoc이 자신이 고친 결함을 적는다: runner가 이벤트당 runOne만 노출하고 순서를 아무도 소유하지 않았으므로, 평범하게 flatMap으로 구독한 caller는 A가 투영 중일 때 B·C를 동시에 날렸고 각자 완료 시 checkpoint를 전진시켰다. B의 checkpoint 뒤 A 완료 전에 프로세스가 죽으면 resume 위치는 이미 A를 지나쳤다 — "A was lost permanently and nothing recorded that it had been."

concatMap이 그 순서를 파이프라인의 성질로 만든다. 그리고 그 위에 high-water mark를 얹어 뒤로 가는 checkpoint를 막는다. 두 장치 모두 의도가 옳다.

67. P1 — high-water mark가 재전달된 이벤트를 삼켜, failover 중이던 변경이 조용히 영구 소실된다

MongoChangeStreamPipeline.processOne은 이벤트를 받자마자 advancesPosition(event.clusterTime())을 부르고, 그 메서드는 getAndAccumulatemark를 먼저 전진시킨 뒤 전진 여부를 반환한다(49·5863행). 즉 mark는 "투영이 완료된 위치"가 아니라 "본 적 있는 위치"다. 그리고 ReactiveMongoChangeStreamConsumer.recoverFrom은 resume 시 Flux.defer(this::openAndConsume)같은 pipeline 인스턴스를 다시 쓴다(199행) — mark는 그대로 남는다.

이 둘이 만나면, MongoChangeStreamPipeline이 고쳤다고 적은 바로 그 손실이 다른 경로로 돌아온다.

실행 probe C(135a-...) — worker 하나, dedup은 항상 claim을 내준다(BUSY 없음). stream 1이 E(clusterTime 5.1)를 내보내고 projector가 200ms를 쓰는 동안, 50ms 시점에 primary가 내려앉는다(errorLabels=[ResumableChangeStreamError], code 133). stream 2는 서버가 resume했을 때 보낼 것 — checkpoint가 E를 지나친 적이 없으므로 E를 재전달하고, 이어서 F(6.1)를 보낸다.

PROBE-C terminal=COMPLETED opens=2
PROBE-C projector started=2 completed=1
PROBE-C results=[MongoChangeProjectionResult[outcome=APPLIED, detail=]]
PROBE-C checkpoints saved=[token-6]
PROBE-C highWaterMark=6.1
PROBE-C state=RUNNING runbook=

E의 투영은 시작됐다가 failover에 취소됐다. resume 후 재전달된 E는 pipeline이 삼켰다 — mark가 E의 첫 전달 때(투영 전에) 이미 5.1로 올라갔기 때문이다. 그 다음 F가 투영되고 checkpoint가 token-6으로 저장되면서, 저장 위치는 E를 지나쳤다. change stream은 checkpoint가 지나친 것을 다시 보내지 않는다. E는 영구히 사라졌고, 구독은 RUNNING에 runbook은 비어 있고, caller의 Flux는 정상 완료한다.

같은 손실이 다른 두 경로로도 확인된다.

  • probe A: E가 BUSY(다른 worker가 claim 보유)로 checkpoint 없이 지나간 뒤 resumable 실패 → resume → E 재전달 → 삼켜짐 → F가 checkpoint를 E 너머로 옮김. token-5 projected? false ; checkpoint moved past it? true.
  • probe B: 실패도 resume도 없이. 하나의 정상 stream에서 E가 BUSY, 이어서 F가 성공. checkpoints saved=[token-6] — E의 checkpoint는 안 썼는데 F의 checkpoint가 E를 지나쳤다. MongoChangeProjectionResult.busy()의 javadoc이 명시한 불변식 — "The checkpoint must not advance past it: the holder may still fail, and a checkpoint that has passed the event is a change the stream will never replay" — 을 바로 다음 이벤트가 깬다. runner는 그 불변식을 지키고, pipeline이 무효화한다.

왜 test가 못 잡았나. 세 테스트가 각각 절반씩 본다. MongoChangeStreamRunnerTest.aBusyClaimNeverAdvancesTheCheckpoint는 이벤트 하나만 돌려서 "그 이벤트의 checkpoint가 안 써졌다"까지만 본다. MongoChangeStreamPipelineTest.anEventBehindTheHighWaterMarkIsDropped는 늦은 이벤트를 버리는 것이 옳다고 단언하는데, 그 시나리오의 늦은 이벤트는 이미 완료된 위치 뒤에 있고, checkpoint store는 NoOpCheckpoints라 상호작용이 보이지 않는다. ChangeStreamConsumerLifecycleTest.aResumableFailureReopensFromTheCheckpoint는 첫 stream을 Flux.error(...)로 시작해 이벤트를 하나도 전달하지 않고 실패시키므로 mark가 설정되지 않는다. "본 적 있지만 완료되지 않은 위치"라는 제3의 상태가 어느 테스트에도 없다.

판정: P1. 조립된 bean에서, 특별한 전제 없이(worker 하나, 평범한 failover), 조용하고 영구적인 변경 소실이 일어나고 시스템은 스스로를 정상이라고 보고한다. 수정 방향은 mark의 의미를 "본 위치"에서 "checkpoint가 저장된 위치"로 바꾸는 것이다 — runOneallowsCheckpointAdvance()인 결과를 낸 뒤에만 mark를 올리고, CLAIMED_ELSEWHERE/PARKED가 나온 위치에서는 mark를 멈춘 채 이후 이벤트의 checkpoint 저장도 그 위치를 넘지 못하게 하는 것(= checkpoint를 순서대로만 전진시키는 것). 최소 수정만으로도 probe C는 막힌다: resume 시 pipeline의 mark를 저장된 checkpoint 위치로 되돌리면 된다.

68. P2 — changeStreams flag는 false로 고정돼 있는데, 소비자 bean은 그것과 무관하게 조립된다

MongoPlatformSettings의 compact 생성자가 changeStreams = false를 강제하고(55행), 그 주석은 이렇게 적는다.

The driver-side source — watch, resumeAfter/startAfter, cursor lifetime, reconnection — is not shipped; what exists is policy and value objects that do not add up to a running consumer… so the value is refused rather than stored: zero beans, zero threads.

HEAD에서 그 전제는 더 이상 사실이 아니다. driver-side source는 SpringReactiveChangeStreamSource출하돼 있고(auto-configuration의 무조건 bean), 완전한 소비자도 조립된다(§65). 주석은 이 코드가 존재하기 전 상태를 서술한다.

결과는 §49의 transaction과 정확히 거울상이다.

flag startup capability 검사 실행체
transactions 살아 있음 TRANSACTION 요구 bean 0
changeStreams 강제 false 절대 실행 안 됨 bean 조립됨

MongoStartupValidator:104changeStreamsEnabled && !capabilities.isStable(CHANGE_STREAM) 검사는 좌항이 영구히 false이므로 도달 불가다. 그래서 change stream을 지원하지 않는 topology(standalone 등)에 완성된 소비자를 배포해도 startup은 통과한다. 실패는 stream을 여는 시점에 driver 오류로 나타나고, MongoChangeStreamRecoveryPolicy.onFailure가 그것을 FAILED + docs/mongodb/runbooks/failover.md로 분류한다 — failover runbook은 "이 topology에는 change stream이 없다"를 설명하지 않는다.

판정: P2. 수정은 셋 중 하나다: changeStreams를 실제 flag로 되살려 소비자 조립의 조건으로 쓰거나, 소비자 bean이 조립될 때 CHANGE_STREAM capability를 startup에서 검증하거나, 최소한 MongoPlatformSettings의 주석을 현재 사실("source는 출하됐고 소비자도 조립된다")로 고치는 것. 지금 주석은 운영자가 읽으면 틀린 결론에 도달한다.

69. P3 — recovery package에 쓰이는 어휘와 쓰이지 않는 어휘가 나란히 있다

135-... §8.3의 검색 결과를 정리하면, 소비자가 실제로 쓰는 것과 아닌 것이 갈린다.

타입/메서드 production 호출
MongoChangeStreamRecoveryPolicy.onFailure 1 (소비자)
MongoInvalidateRecovery.requireCorrectResumeOption 1 (소비자)
onHistoryLost, onResumableFailure, onInvalidate 0 (test만)
MongoInvalidateRecovery.checkpointFor 0 — 소비자는 tokens.encode(..., START_AFTER)로 직접 만든다
MongoChangeStreamState.autoResumable() 0 — 소비자는 decision.autoResume()을 쓴다
MongoChangeHistoryLostException 0 — 어디에서도 생성되지 않는다

마지막 항목이 가장 무겁다. 이 예외의 javadoc은 왜 전용 타입이어야 하는지를 설명한다("the recovery is a business decision, not a technical one"). 그런데 실제로 history lost가 감지되면(onFailure → server code 286/280) 소비자는 state를 HISTORY_LOST로 놓고 driver의 원본 예외를 그대로 재방출한다. lifecycle test가 그것을 고정한다: verifyError(MongoQueryException.class). 그래서 caller가 catch (MongoChangeHistoryLostException)로 이 상황을 구분하려 하면 절대 잡히지 않는다.

그리고 소비자의 유일한 requireCorrectResumeOption 호출은 자기 자신과 비교한다(ReactiveMongoChangeStreamConsumer:119: requireCorrectResumeOption(checkpoint, checkpoint.position())). probe D로 확인했다 — 이 호출 형태는 구조적으로 던질 수 없고, 다른 intended를 넘기는 production 호출은 없다. 안전장치처럼 읽히지만 검사하는 것이 없다. P3.

70. Negative-space probes — sub-scope 08

  • 8.1 reachability: 이 sub-scope는 조립돼 있다 — source bean 무조건, consumer bean은 fork의 5종 SPI에 조건부(§65). platform이 제공하는 SPI 구현은 0(전부 test fixture) — 설계상 fork 몫.
  • 8.2 계약 ↔ 구현: busy()가 선언한 불변식을 pipeline이 깬다(§67, probe B). MongoChangeStreamPipeline javadoc이 고쳤다고 적은 손실이 mark의 의미 때문에 되돌아온다(probe A·C).
  • 8.2b 테스트 사각지대: "본 적 있지만 완료되지 않은 위치"가 세 테스트 어디에도 없다(§67).
  • 8.3 중복 메커니즘: recovery의 두 어휘(§69). checkpoint 생성 경로 둘(checkpointFor vs tokens.encode). 자기 자신과 비교하는 guard.
  • 8.4 문서 drift: MongoPlatformSettings의 "zero beans, zero threads" 주석이 현재 코드와 어긋난다(§68). 반면 policy가 지목하는 두 runbook(docs/mongodb/runbooks/history-lost.md, failover.md)은 실재한다 — confirmed match.

71. Sub-scope 08 findings backlog

우선순위 finding reachability
P1 pipeline의 high-water mark가 "투영 완료 위치"가 아니라 "본 위치"이고 resume에도 유지되므로, failover 중이던 이벤트가 재전달 시 삼켜지고 이후 이벤트의 checkpoint가 그것을 지나친다 — 조용한 영구 소실, state는 RUNNING (probe C) 조립된 소비자 + 임의의 resumable failover. worker 하나로 재현
P1(동일 결함, 별 경로) 실패가 전혀 없어도 CLAIMED_ELSEWHERE(및 PARKED) 위치를 이후 이벤트의 checkpoint가 지나친다 — busy()의 javadoc이 명시한 불변식 위반 (probe B) 다중 worker 배포
P2 changeStreamsfalse로 고정돼 startup의 CHANGE_STREAM capability 검사가 도달 불가인데 소비자 bean은 조립된다. MongoPlatformSettings의 "not shipped / zero beans" 주석이 현재 코드와 어긋난다 change stream 미지원 topology에 배포하는 모든 fork
P3 MongoChangeHistoryLostException이 어디에서도 생성되지 않는다 — history lost는 driver 원본 예외로 재방출된다 이 상황을 타입으로 구분하려는 caller
P3 requireCorrectResumeOption(checkpoint, checkpoint.position()) — 자기 자신과 비교하는 guard 소비자의 유일한 호출
P3/기록 onHistoryLost·onResumableFailure·onInvalidate·checkpointFor·autoResumable() production 호출 0 — 쓰이는 어휘와 쓰이지 않는 어휘가 나란히 있다 유지보수

72. Sub-scope 08 완료 조건

  • denominator 26 / 26 FULL_READ (135-... OWNED FILES)
  • reachability·계약대조·테스트사각지대·중복메커니즘·문서drift 5종 probe 수행
  • P1을 세 개의 독립적인 실행 probe(A·B·C)로 확정, auto-configuration과 동일한 조립으로 재현(135a-...)
  • 임시 probe class 2개 추가 후 제거, mongoStableContractTest 재실행 green, git status --short = 0

73. Sub-scope 09 범위와 denominator

내부 상태: COMPLETE — 44 / 44 FULL_READ 범위: security/** 13 + failure/** 8 + observation/** 7 + client/** 1 (production 30, 2,244 LOC) + 전용 test 14 (1,885 LOC) 역할: 자격증명 분리와 D4 admin plane, driver 실패의 단일 번역 지점, 태그 allowlist 기반 관측, 그리고 프로파일 → driver 설정 변환

manifest와 정적 probe: evidence/raw/136-mongo-security-failure-observation-client-probes.txt. 실행 probe: evidence/raw/136a-mongo-client-settings-execution-probe.txt.

74. failure는 이 leaf에서 가장 잘 배선되고 가장 잘 논증된 부분이다

MongoFailureClassifierMongoFailureTranslator는 auto-configuration의 실제 bean이고(MongoPlatformAutoConfiguration:85·92), imperative·reactive 두 executor가 모두 그것을 통해 번역한다. 규칙 사슬도 순서까지 논증돼 있다 — label → phase → 적용 가능성 → code table → fail closed.

Phase sits above the code table because a failure that never reached a server is safe to repeat whatever code accompanies it, and a commit failure is unsafe to replay whatever code accompanies it — both were decided by the code table before, and the code table knows neither.

고쳐진 결함 이력이 촘촘하다.

  • 번역기가 phase를 버렸다. DefaultMongoFailureTranslator가 operationType을 들고도 context-free overload를 불러서, "FIND의 응답 유실 = 재현 가능한 읽기 / UPDATE의 같은 유실 = 결과 불명 쓰기"라는 구분이 transaction이 아닌 모든 경로에서 버려졌다 — 즉 모든 평범한 연산에서. 실패한 읽기가 ambiguous write로 보고됐다.
  • server-selection이 terminal이었다. label도 code도 없는 실패가 UNCLASSIFIED로 떨어져 재시도 불가로 처리됐다 — 재시도가 명백히 안전한 유일한 경우인데.
  • Spring 래핑이 분류를 통째로 건너뛰었다. MongoFailureExtractor가 그 수리다. cause 사슬을 깊이 16까지, IdentityHashMap으로 순환 안전하게 탐색한다("a cycle is about the same object appearing twice").
  • message는 절대 읽지 않는다. MongoDriverFailureView가 driver 예외를 label·code·boolean 둘로 좁히는 지점이고, 그 이후 어느 계층도 나머지에 닿을 수 없다 — "no later layer can reach the rest, because no later layer is ever handed it".

MongoFailureClassification의 생성자가 COMMIT_ONLYTRANSACTION_COMMIT_UNKNOWN에만 허용하는 것도 §15의 불변식과 맞물린다.

security도 대부분 배선돼 있다. MongoStartupValidator:62MongoSecurityProfileValidator().validate(runtimeSecurity)를, :117requireDistinctCredentials를 부른다. MongoPlatformAutoConfiguration:341350은 셋 중 하나라도 없으면 부분 검증 대신 startup을 거부한다("A partial startup check reports success for the parts nobody supplied"). MongoCredentialReference.fingerprint()의 주석은 이 leaf에서 가장 좋은 결함 서술 중 하나다 — role을 해시에 섞은 탓에 "같은 secret, 다른 role"이 다른 지문을 냈고, 그 지문을 쓰는 유일한 검사인 requireDistinctCredentials항상 runtime role과 admin role로 호출되므로 결코 발화할 수 없었다.

observation의 태그 allowlist와 MongoObservationRedactor의 allowlist 방향("a denylist would have to anticipate the next command MongoDB adds that happens to carry a secret")도 일관된다. driver 리스너는 MongoDriverObservabilityAutoConfigurationMongoClientSettingsBuilderCustomizer로 등록해 실제로 설치된다 — 그 파일의 javadoc이 자기 존재 이유를 적는다: "MongoDriverObservabilityConfiguration could add command, SDAM and pool listeners to a settings builder, and nothing ever called it… the pool-checkout, server-selection and primary-change metrics the operations documentation refers to were never emitted."

75. P1 — 프로파일의 TLS·타임아웃·풀·Stable API가 driver에 도달하지 않는다

MongoClientSettingsFactory의 javadoc은 자신이 무엇을 고치려고 만들어졌는지 적는다.

The profile, the credential resolver, the TLS and Stable-API flags and the pool and timeout policy all existed and were all unit-tested. None of them reached a MongoClientSettings… A policy that nothing applies reads exactly like a policy that is applied — the tests pass, the record is populated, and the client connects with a three-second timeout it inherited from the driver rather than the two the profile states.

HEAD에서 이 클래스는 저장소 전체에서 호출자가 없다(136-... §8.1b·§8.1e: 자기 파일과 자기 test 외의 참조 0, app-bootstrap 포함 repo-wide 0). bean도 아니다. MongoCredentialResolver도 production에서 한 번도 호출되지 않는다 — 유일한 외부 언급은 모듈 CLAUDE.md의 산문이다. 실제 client는 Spring Boot가 spring.data.mongodb.uri에서 만든다(모듈 README:37이 그 형태를 그대로 보여 준다). 즉 수리 코드는 작성됐고 배선되지 않았다.

hermetic 실행 probe(136a-...)로 결과를 측정했다.

PROBE profile.tlsRequired=true -> validator ACCEPTED
PROBE settings Boot builds from the README's URI:
        sslEnabled=false
        connectTimeoutMs=10000        serverSelectionTimeoutMs=30000
        poolMaxSize=100               serverApi=null
        uuidRepresentation=UNSPECIFIED
PROBE settings MongoClientSettingsFactory would build: sslEnabled=true

가장 무거운 줄은 첫 두 줄이다. MongoSecurityProfileValidator는 production 프로파일이 TLS를 요구한다고 선언하면 통과시키고, 선언하지 않으면 startup을 거부한다 — 그리고 그 선언을 연결에 적용하는 코드는 없다. TLS가 켜지는 것은 오직 fork의 URI에 tls=true가 들어 있을 때뿐이다. 프로파일이 "TLS 필수"라고 말하고, 검증기가 그것을 확인하고, 연결은 평문으로 나갈 수 있다. 나머지 줄들도 같은 성질이다 — 타임아웃·풀 상한·Stable API strict·고정 UUID 표현이 전부 driver 기본값이다(serverApi=null은 strict Stable API가 없다는 뜻이고, uuidRepresentation=UNSPECIFIEDMongoClientSettingsFactory가 "a value that moves under a stored document is a migration nobody wrote"라며 고정하려던 바로 그 값이다).

같은 결함의 형제가 이미 고쳐져 있다는 점이 이 finding을 결정적으로 만든다. 관측 쪽도 "설정 빌더에 적용하는 메서드에 호출자가 없다"는 똑같은 형태였고, 그쪽은 MongoDriverObservabilityAutoConfigurationMongoClientSettingsBuilderCustomizer를 등록해서 고쳤다. 동일한 메커니즘이 같은 패키지에 있고, 설정 절반에는 쓰이지 않았다.

기존 test는 이 경계를 보지 못한다. MongoClientSettingsFactoryTest는 factory를 직접 생성해서 프로파일이 설정에 도달하는지 확인한다 — factory가 호출된다는 전제 아래. MongoTlsLaneTestapplyToSslSettings(ssl -> ssl.enabled(true))손수 만든 설정으로 서버가 TLS를 강제하는지 확인한다(:137). 어느 쪽도 "프로파일의 tlsRequired가 실제 연결을 TLS로 만드는가"를 묻지 않는다.

판정: P1. 수리는 이미 있는 형태를 따르면 된다 — MongoClientSettingsBuilderCustomizer bean 하나가 MongoClientSettingsFactory(또는 그 build 로직)를 Boot의 빌더에 적용하게 하는 것. 그때 MongoCredentialResolver도 비로소 경로에 들어온다.

76. P3 — admin gateway의 두 audit 경로 중 하나만 fail-closed다

MongoAdminGateway.execute는 모든 audit 쓰기를 audit(...) 헬퍼로 보내고, 그 헬퍼는 sink 실패를 MongoOperationRejectedException으로 바꾼다 — "an administrative operation that cannot be audited does not run". MongoAdminAuditStateMachineTest.anUnauditableCommandDoesNotRun이 그것을 고정한다.

dryRun(...)(:145149)은 auditSink.accept(...)직접 부른다. 헬퍼를 거치지 않으므로 sink 실패가 platform 예외로 번역되지 않고 raw로 전파된다. 그리고 dry run은 장식이 아니다 — 고위험 작업의 전제 조건이고, 그래서 "a first-class call rather than a flag somebody remembers to pass"로 만들어졌다. 감사되지 않은 dry run 위에 승인이 얹히면 승인 사슬의 첫 칸에 기록이 없다. P3(전파는 되므로 조용히 통과하지는 않는다; 다만 형제 경로와 동작이 다르고 그 차이가 문서화돼 있지 않다).

77. P3 — 태그 allowlist는 규약이지 강제가 아니다

MongoObservationConvention의 javadoc은 강제라고 말한다.

a tag not on this list cannot be attached, so the mistake has to be made in this file rather than at a call site.

실제로는 requireAllowed(...)를 부르는 production 코드가 없다(136-... §8.2). 네 개의 관측 클래스는 전부 Tags.of("...", ...)로 문자열을 직접 넣는다. 현재 값들은 모두 allowlist 안에 있으므로 지금은 어긋남이 없지만, 그 사실은 코드가 아니라 리뷰와 MongoObservationConventionTest가 지키고 있다. 새 리스너를 추가하는 사람은 이 파일을 열 이유가 없다.

MongoObservationRedactor.describe(...)도 production 호출자가 0이다 — MongoCommandObservationListenerisAlwaysRedacted만 쓴다. 세 갈래(안전/기본/항상 가림) 중 실제로 쓰이는 것은 "항상 가림" 하나다. P3.

78. Confirmed — 세 곳의 대비: 배선된 것, 부분적으로 배선된 것, 배선되지 않은 것

이 sub-scope는 앞선 sub-scope들과 달리 세 상태가 한 화면에 있다.

패키지 상태
failure 완전 배선. classifier·translator 모두 bean, 두 executor가 사용, MongoFailureExtractor는 두 session factory가 사용
security 검증 경로 배선. MongoStartupValidator가 profile validator와 자격증명 분리 검사를 실행. 다만 그 검증 대상 선언이 driver에 적용되지 않는다(§75)
observation 부분 배선. driver 리스너는 customizer로 설치됨. allowlist 강제와 describe는 미사용(§77)
client 미배선. 호출자 0(§75)

호출자 없는 잔여물도 정리해 둔다: MongoFailureClassification.unrecognisedServerCode() 0, MongoAdminAuditRecord.applied(...)("legacy shape, kept for callers that do not build a command") production 0 / test 2, MongoAdminRuntimeGuard.adminGatewayAllowed() production 0, MongoDriverObservabilityConfiguration.convention() 0. 어느 것도 결함은 아니지만, 이 leaf가 "쓰이는 어휘와 쓰이지 않는 어휘를 나란히 둔다"는 §69의 패턴이 여기서도 반복된다.

79. Negative-space probes — sub-scope 09

  • 8.1 reachability: 네 패키지의 상태가 서로 다르다(§78). MongoClientSettingsFactory repo-wide 호출자 0(§75).
  • 8.2 계약 ↔ 구현: MongoClientSettingsFactory javadoc이 서술한 결함이 그 클래스 자체에 대해 성립한다(§75). allowlist javadoc의 "cannot be attached"와 실제 강제 부재(§77).
  • 8.2b 조건부 형제: 같은 결함(설정 빌더 메서드에 호출자 없음)의 두 수리 중 관측 쪽만 배선(§75). executedryRun의 audit 경로 차이(§76).
  • 8.3 중복/미사용 메커니즘: §78 말미 목록. redactor의 세 갈래 중 하나만 사용(§77).
  • 8.4 lane drift: build.gradle에 6개 lane(mongoReplicaSetTest·mongoFailoverTest·mongoMigrationTest·mongoCompatibilityTest·mongoSecurityIntegrationTest·mongoPerformanceTest) 정의, tag는 각각 대응. MongoTlsLaneTest·MongoSecurityIntegrationLaneTestmongodb-security-integration, MongoNetworkFaultLaneTestmongodb-failover — 전부 정의된 lane에 매핑된다. confirmed match.

80. Sub-scope 09 findings backlog

우선순위 finding reachability
P1 MongoClientSettingsFactory가 저장소 전체에서 호출되지 않아 프로파일의 tlsRequired·타임아웃·풀 상한·Stable API·UUID 표현이 driver에 도달하지 않는다. 검증기는 "TLS 필수" 선언을 통과시키고 연결은 평문일 수 있다 (probe: tlsRequired=true → validator ACCEPTED, Boot 설정 sslEnabled=false) 이 leaf를 켠 모든 배포
P3 MongoAdminGateway.dryRun이 fail-closed audit(...) 헬퍼를 우회해 sink 실패를 raw로 전파한다 — execute와 동작이 다르다 dry run을 감사하는 배포
P3 태그 allowlist(requireAllowed)와 MongoObservationRedactor.describe의 production 호출자 0 — javadoc이 주장하는 강제는 규약과 test가 지킨다 리스너를 추가하는 시점
P3/기록 호출자 없는 잔여 API: unrecognisedServerCode(), MongoAdminAuditRecord.applied(...), adminGatewayAllowed(), MongoDriverObservabilityConfiguration.convention() 유지보수

81. Sub-scope 09 완료 조건

  • denominator 44 / 44 FULL_READ (136-... OWNED FILES)
  • reachability·계약대조·조건부형제·중복메커니즘·lane drift 5종 probe 수행
  • P1을 hermetic 실행 probe로 확정하고, 기존 두 test(MongoClientSettingsFactoryTest·MongoTlsLaneTest)가 왜 그 경계를 보지 못하는지 코드로 확인(136a-...)
  • 임시 probe class 1개 추가 후 제거, git status --short = 0

82. Sub-scope 10 범위와 denominator

내부 상태: COMPLETE — 75 / 75 FULL_READ 범위: advanced/** 65 (production, 3,439 LOC) + 전용 test 10 (1,365 LOC) 하위 영역: root(6) · autoconfigure(2) · bridge(7) · encryption/csfle(5) · encryption/qe(6) · gridfs(4) · search(5) · sharding(6) + sharding/admin(3) · tenancy/database(5) + tenancy/shared(4) · timeseries(6) · vector(5) 역할: Stable lane이 갖지 못한 것(샤딩 클러스터·Atlas·KMS·별도 자격증명)을 요구하는 능력들을 명시적 opt-in으로 격리한다

manifest와 probe: evidence/raw/137-mongo-advanced-manifest-and-probes.txt.

83. opt-in 구조 자체가 이 sub-scope의 본체다

세 겹으로 되어 있다.

  1. 분류 어노테이션 둘. @MongoAdvancedEntryPoint(capability)실행하는 타입, @MongoAdvancedPolicy판단·기술·검증만 하는 타입. 후자를 flag 뒤에 두지 않는 이유가 적혀 있다 — "gating it behind a capability flag would only make a shard-key analysis or a manifest check unavailable to the very people deciding whether to turn the capability on."
  2. guard. entry point는 MongoAdvancedCapabilityGuard를 생성자 인자로 받아 자기 자신을 넘겨 검사시킨다. 필요한 capability는 타입 위의 어노테이션에서 읽으므로 호출자마다 복사되지 않는다. 어노테이션 없는 타입이 guard에 물으면 IllegalArgumentException이다 — "defaulting to 'allowed' is how the invariant was lost in the first place."
  3. ArchUnit 규칙 둘. MongoAdvancedRules.everyAdvancedTypeIsClassified()everyEntryPointConsultsTheGuard(). "Two rules, because one alone is escapable."

MongoAdvancedEntryPoint의 javadoc이 이 구조가 왜 생겼는지 적는다.

The module documentation claimed that "every Advanced entry point refuses construction unless its capability is enabled". Of the concrete classes under this package only four referenced the flags at all; the rest — a change-stream-to-messaging bridge, a per-tenant client registry, a tenant migration coordinator — were constructible and runnable with every Advanced capability switched off. The invariant was documentation, not behaviour.

그리고 MongoAdvancedSettings가 그 위의 결함을 고친다 — flag는 "무엇이 켜졌나"를 답할 줄 알았지만 그 property를 읽는 코드가 없었다. 그래서 ca-skeleton.persistence-mongo.advanced.sharding.enabled=true를 설정해도 아무 일도 일어나지 않았다. 이제 @ConfigurationProperties로 바인딩되고, 바인딩 키가 MongoAdvancedCapabilityFlags.propertyFor(...)가 거부 메시지에 적는 경로와 같은지 test가 고정한다.

MongoAdvancedConfiguration의도적으로 auto-configuration이 아니다AutoConfiguration.imports에 없고(137-... §8.1: grep exit=1), 이 leaf의 main 안에서 MongoAdvancedCapabilityGuard를 참조하는 non-advanced 코드도 0이다. composition root가 이름으로 import해야 하고, 그 import 자체가 opt-in이다.

84. Confirmed — 분류 불변식이 실제로 성립한다

세어 봤다(137-... §8.1b·§8.1c).

  • @MongoAdvancedEntryPoint 7개: MongoChangeMessagingBridge(CHANGE_STREAM), MongoCsfleClientFactory(CSFLE), MongoQueryableEncryptionCollectionManager(QUERYABLE_ENCRYPTION), MongoGridFsMigrationJob(GRIDFS_COMPATIBILITY), MongoShardingAdminGateway(SHARDING), MongoTenantClientRegistry·MongoTenantMigrationCoordinator(DATABASE_PER_TENANT).
  • @MongoAdvancedPolicy 11개.
  • 어느 쪽도 아닌 구체 클래스 1개: MongoAdvancedConfiguration. 이것은 누락이 아니다 — MongoAdvancedRules.concreteClass()@Configuration을 명시적으로 제외하며 이유를 적는다: "A @Configuration class is the package's composition root: it builds entry points through the guard rather than being one, and gating it would gate the thing that supplies the guard." interface·enum·record·익명·private 중첩·abstract도 같은 방식으로 제외되고 각각 근거가 붙어 있다.

즉 §83이 말하는 불변식은 문서가 아니라 코드로 서 있다. 이 leaf에서 "문서가 주장하고 코드가 지키지 않는다"를 여러 번 본 뒤라, 여기서는 그 반대가 성립한다는 것을 명시해 둘 가치가 있다.

또 하나의 confirmed: throw new UnsupportedOperationException만 하는 public 메서드를 값으로 바꾼 수리가 두 곳에서 같은 형태로 이루어졌다. MongoTimeSeriesCapabilityValidator는 네 개의 던지기만 하는 메서드를 supportFor(capability) → MongoTimeSeriesSupport(지원 여부 + 이유)로 바꿨고, MongoQueryableEncryptionProfile은 세 개의 던지기만 하는 static factory를 supportFor(MongoQueryShape) → MongoQueryShapeSupport로 바꿨다. 근거도 동일하다 — "A factory that never returns is not an API: it cannot appear in working code, so its only reachable use is a test asserting that it throws, and the design-time question it was meant to answer is only answered by running it."

85. P2 — sharding admin gateway의 네 작업 중 셋은 어떤 입력으로도 완료될 수 없다

MongoShardingAdminGateway는 네 메서드 모두 MongoAdminGateway5인자 편의 오버로드를 부른다(:64, :75, :86, :92). 그 오버로드는 MongoAdminCommand.routine(...)을 만들고 **approval = null**을 넘긴다(MongoAdminGateway:6367). 그리고 실제 실행 경로는 고위험 작업에 대해 approval == null이면 거부한다(:9497).

MongoAdminOperation에서 SHARD_COLLECTION·REFINE_SHARD_KEY·RESHARD_COLLECTION은 전부 highRisk(true)이고, BALANCER_CONTROLfalse다.

실행 probe로 확인했다(137-... PROBE). 입력은 통과할 수 있는 모든 증거를 갖췄다 — SHARDING capability 활성화, MongoAdminAuthorization.approved(네 작업, "release-engineer")(= 이름 있는 승인자 + 완료된 dry run), 승인된 ShardKeyReadinessReport, 완전한 ReshardApproval(승인된 readiness + dry run 완료 + 승인자 + 문서화된 forward strategy), shard key로 시작하는 지원 인덱스, 만료되지 않은 command clock.

PROBE shardCollection  -> REFUSED: admin operation SHARD_COLLECTION destroys data or rewrites
                          a collection; it runs under an approval bound to this exact command
                          or not at all
PROBE refineShardKey   -> REFUSED (REFINE_SHARD_KEY, 같은 메시지)
PROBE reshardCollection-> REFUSED (RESHARD_COLLECTION, 같은 메시지)
PROBE controlBalancer  -> APPLIED
PROBE bodies actually executed = 1 of 4

구조적 원인은 승인 어휘가 둘이라는 것이다. sharding 모듈은 자기 몫의 완전한 승인 객체(ReshardApproval, 네 가지 증거)를 만들어 스스로 검사한 뒤, 실제로 결정하는 D4 plane에는 그 중 아무것도 넘기지 않는다. D4가 요구하는 것은 MongoAdminApproval(command digest에 바인딩된 단일 사용 승인)이고, 그것을 만드는 코드가 sharding 쪽에 없다.

test도 이 경계를 보지 않는다: MongoShardingAdminGateway를 참조하는 곳은 자기 선언 세 줄뿐이다(137-... §8.2c). sharding 관련 test 둘(ShardKeyAnalyzerTest·ShardAwareQueryValidatorTest)은 policy 계층만 다룬다.

판정: P2. 데이터 위험은 없다 — 거부는 fail-closed이고, 오히려 안전한 방향으로 틀렸다. 위험은 능력이 문서상 존재하고 실제로는 없다는 것이며, 그 사실이 발견되는 시점은 운영자가 프로덕션 클러스터에서 reshard를 실행하려는 순간이다. 수정은 세 메서드가 MongoAdminCommand.over(...) + MongoAdminApproval.of(command, approver, expiry)를 만들어 2인자 execute에 넘기고, ReshardApproval의 증거를 그 승인의 전제로 쓰는 것이다.

86. P3 — promotion 증거 어휘가 둘이고, gate는 하나만 검사한다

MongoAdvancedPromotionEvidence.REQUIRED는 여섯 범주다: stable-platform, actual-topology, security, migration, failure, runbook. MongoAdvancedPromotionGate.verify(...)가 그 여섯을 전부 검사한다 — 그리고 그 파일에는 고쳐진 결함이 주석으로 남아 있다: "migration was in MongoAdvancedPromotionEvidence.REQUIRED and not here, so the gate demanded five of the six categories it declares… which is the shape MNG-008 names: a gate that certifies more than it ran."

그런데 MongoVectorSearchBenchmarkGate.requiredEvidence()완전히 다른 다섯 범주를 반환한다: index-readiness, recall, latency, memory, actual-topology. 겹치는 것은 actual-topology 하나뿐이고, 이 집합을 읽는 production 코드는 없다(137-... §8.3). MongoAdvancedPromotionGate는 이 집합을 모른다.

그래서 vector search를 promotion하는 경로는 MongoAdvancedPromotionGate.verify를 통과할 수 있고, 그 통과는 recall·latency·index memory에 대해 아무것도 말하지 않는다MongoVectorSearchBenchmarkGate의 javadoc이 정확히 그 위험을 적는데도: "Functional success is not evidence for vector search. An approximate index returns results for any query; whether they are the right results depends on recall." 방금 migration 누락으로 고쳤던 것과 같은 모양(선언한 것보다 적게 검사하는 gate)이 모듈 경계를 건너 다시 나타난다. P3.

87. P3/기록 — change stream checkpoint를 쓰는 곳이 둘이고, 서로를 모른다

MongoResumeCheckpointStore.save(...)를 부르는 production 코드는 둘이다(137-... §8.3c).

  • MongoChangeStreamRunner:75 — 투영이 성공한 뒤.
  • MongoChangeMessagingBridge:60·84 — 매핑하지 않은 변경(:60)과 broker가 수락한 변경(:84) 뒤.

둘 다 옳게 설계돼 있고(bridge는 MongoPublishResult가 broker의 실제 답을 나르게 만들어, 상수 때문에 두 분기가 모두 도달 불가였던 결함을 고쳤다), 각자 "손실보다 중복"을 택한다. 문제는 한 subscription에 둘 다 배선되는 경우 서로의 진행을 모른다는 것이다. 각자 자기 성공에서 checkpoint를 전진시키므로, bridge가 앞서면 projector가 아직 처리하지 않은 변경을 지나치고 그 반대도 마찬가지다. §67에서 본 pipeline의 high-water mark 문제와 합쳐지면 결과는 같은 방향 — 조용한 소실 — 이다.

두 클래스 어디에도 "한 subscription에 하나만 배선하라"는 진술이 없다. MongoChangeMessagingBridgeMongoChangeProjector가 아니라 별도 타입이라는 사실 자체가 둘을 함께 쓸 수 있다는 신호로 읽힌다. P3/기록 — fork의 조립 결정이므로 지금 결함은 아니지만, 계약이 어디에도 없다.

88. P3 — 구현 없는 4개의 계약 중 셋은 그 사실을 적고, 하나는 적지 않는다

MongoSearchOperations·MongoTimeSeriesOperations·MongoVectorSearchOperations는 모두 동일한 문단을 담는다.

Scaffold. This repository ships no implementation… Read a method signature as a specification, not as an available capability — an interface with no implementation cannot be injected, and treating it as shipped behaviour is how "the platform supports search" becomes true in a document and false in a deployment.

훌륭한 자기 한정이고, 이 leaf에서 반복적으로 필요했던 종류의 정직함이다. 그런데 TenantScopedMongoOperations도 구현이 0인데(137-... §8.3d: 네 interface 모두 implements 검색 exit=1) 그 문단이 없다. 그리고 이 넷 중 오해가 가장 비싼 것이 바로 그것이다 — javadoc이 "Operations that cannot run without a tenant predicate"라고 시작하므로, 능동적인 안전장치로 읽힌다. 실제로 그 보장을 제공하는 것은 MongoTenantPredicateInjector(policy, 구현 있음)이고, 이 interface는 fork가 구현했을 때만 그 injector를 부르게 되는 형태일 뿐이다. P3.

89. Negative-space probes — sub-scope 10

  • 8.1 reachability: guard bean은 MongoAdvancedConfiguration에만 있고 그것은 auto-load되지 않는다 — 저장소 안에 이것을 import하는 곳이 없으므로 모든 Advanced entry point는 기본 배선에서 도달 불가다. 이것은 설계이고 문서와 일치한다(confirmed).
  • 8.1b 분류 완전성: 7 entry point + 11 policy + 1 의도적 제외 = 19개 구체 클래스 전부 설명됨(§84). ArchUnit 규칙이 양쪽을 강제.
  • 8.2 공개 표면 도달성: MongoShardingAdminGateway의 4개 중 3개가 어떤 입력으로도 완료 불가(§85, 실행 probe).
  • 8.2b 중복 로직: shard key ↔ 유니크 인덱스 호환성 검사가 ShardKeyDescriptor.supportsUniqueIndexOnMongoShardingAdminGateway.shardCollection 안에 각각 있다(후자는 전자를 부르지 않고 sublist 비교를 다시 쓴다). 두 구현의 결과는 현재 같다.
  • 8.3 중복 메커니즘: promotion 증거 어휘 둘(§86), checkpoint 작성자 둘(§87), 승인 어휘 둘(§85).
  • 8.4 문서 drift: 모듈 CLAUDE.md:142가 "MongoAdvancedConfiguration is imported by name, never auto-loaded"라고 적고 실제로 그렇다 — confirmed match. build.gradle에 advanced 전용 lane은 없고, advanced test는 hermetic mongodb-contract 레인에서 돈다.

90. Sub-scope 10 findings backlog

우선순위 finding reachability
P2 MongoShardingAdminGatewayshardCollection·refineShardKey·reshardCollection이 5인자 execute(approval=null)를 쓰므로 고위험 작업 거부에 걸려 완료 불가. 자기 몫의 ReshardApproval을 만들고도 D4가 요구하는 MongoAdminApproval은 만들지 않는다. gateway를 구동하는 test 0 SHARDING을 켠 fork가 샤딩을 실제로 수행하려는 시점
P3 promotion 증거 어휘가 둘(MongoAdvancedPromotionEvidence.REQUIRED 6종 vs MongoVectorSearchBenchmarkGate.requiredEvidence() 5종, 교집합 1)이고 gate는 전자만 검사한다 — vector 승격이 recall·latency·memory 증거 없이 통과한다 vector search 승격 절차
P3 TenantScopedMongoOperations는 구현이 없는데 형제 셋과 달리 Scaffold 고지가 없고, javadoc은 능동적 안전장치처럼 읽힌다 문서/조립
P3/기록 MongoChangeStreamRunnerMongoChangeMessagingBridge가 같은 MongoResumeCheckpointStore를 독립적으로 전진시키며, 한 subscription에 둘을 배선하지 말라는 계약이 없다 두 소비자를 함께 배선하는 fork
P3/기록 shard key ↔ 유니크 인덱스 호환성 검사가 두 곳에 중복 구현돼 있다 유지보수

91. Sub-scope 10 완료 조건

  • denominator 75 / 75 FULL_READ (137-... OWNED FILES)
  • reachability·분류완전성·공개표면도달성·중복로직·중복메커니즘·문서drift 6종 probe 수행
  • P2를 hermetic 실행 probe로 확정(모든 승인 증거를 갖춘 입력에서 4개 중 1개만 실행)
  • ArchUnit 분류 규칙의 예외(@Configuration)가 의도된 것임을 규칙 소스로 확인
  • 임시 probe class 1개 추가 후 제거, git status --short = 0

92. Sub-scope 11 범위와 denominator

내부 상태: COMPLETE — 49 / 49 FULL_READ 범위: src/testkit 35 (3,036 LOC) + src/test의 미배정 13 (architecture 4, rs 2, compat 1, release 1, testkit-검증 3, 루트 2 — 1,466 LOC) + src/mongoPerformanceTest 1 (194 LOC) 역할: 이 leaf의 인증 장치 — 실제 토폴로지 fixture, 아키텍처 규칙, 릴리스 증거 검증

manifest와 probe: evidence/raw/138-mongo-testkit-release-lanes-probes.txt.

93. Confirmed — testkit은 흉내내지 않고 진짜를 만든다

이 sub-scope에서 가장 인상적인 것은 fixture들이 어려운 쪽을 선택했다는 점이다.

  • MongoThreeNodeReplicaSetMongoDBContainer쓰지 않는다 — 그 컨테이너는 시작할 때 자기만의 단일 노드 set을 initiate하므로 "세 개를 띄우면 아무것도 선출하지 않는 세 개의 별도 클러스터"가 된다. 대신 --replSet만 주고 하나의 rs.initiate로 묶는다. primary는 묻는다(db.hello().primary), 어느 컨테이너가 살아 있는지로 추론하지 않는다 — "inferring it from which containers are still running produces a fixture that reports an election that never happened."
  • ToxiproxyMongoNetworkFaultController응답 방향만 끊는다(ToxicDirection.DOWNSTREAM). 그것이 WRITE_RESULT_UNKNOWN을 만드는 유일한 방법이다 — 컨테이너를 죽이면 클라이언트는 쓰기가 일어나지 않았음을 알게 되고, 그것은 이미 다루어진 쉬운 실패다. MongoProxiedReplicaSetNode는 같은 서버로 가는 두 경로(직접/프록시)를 둔다 — 주입한 결함이 서버 결함이 아니라 경로 결함임을 보이려면 프록시를 우회한 두 번째 클라이언트가 서버를 건강하다고 확인해 주어야 하기 때문이다.
  • MongoAuthenticatedReplicaSetContainer--auth와 keyfile을 컨테이너 안에서 생성한다 — "MongoDBContainer starts mongod without --auth. Users can be created on it and every one of them can do everything, so a least-privilege test against it passes no matter how wrong the roles are. A security lane that cannot fail is not a security lane." root 비밀번호는 인스턴스마다 SecureRandom으로 만든다(과거에는 소스 상수였고, 그 주석이 왜 그것이 문제인지 적는다).
  • MongoSingleReplicaSetContainer.providesFailoverEvidence()항상 false를 반환하며 그 이유를 문서화한다 — 단일 노드 set은 선출을 하지 않는다.
  • MongoBsonSnapshot은 JSON으로 변환하지 않고 BSON 타입을 보존한 채 정규화한다 — JSON으로 가면 Decimal128과 문자열이 같아지고, missing과 explicit null이 같아진다. 키 집합을 정규형의 일부로 렌더링해 그 둘을 분리한다.

MongoAccessRules의 존재 이유도 이 leaf의 반복 주제다: MongoRepositoryArchitectureRules는 타입 이름의 Set<String>을 반환했고 그 test는 집합의 내용만 단언했다. "a controller must not hold a MongoTemplate"은 Set<String>에 대한 통과하는 test였고 컨트롤러는 아무 규칙의 지배도 받지 않았다 — "and Boot's own auto-configuration supplies exactly those beans, so the injection was one constructor parameter away." 지금은 ArchUnit 규칙이 실제 클래스 그래프에 적용된다.

MongoModuleBoundaryTest도 confirmed다. 닫힌 edge 행렬을 트리와 정확히 일치하는지 비교하고, DAG 밖의 네 간선(reactive → imperative, reactive → query, transaction → reactive, geo → imperative)을 제거하는 대신 기록한다 — "Each is a real coupling the code relies on, and pretending otherwise is what the previous rules did; recording them makes the next one a decision instead of an accident."

94. P2 — 커버리지 gate 둘이 나란히 있고, 하나는 발화할 수 없다

MongoStableContractSuite의 javadoc이 존재 이유를 적는다.

The report distinguishes a failed contract from a contract that never ran. A suite that reports "no failures" because half of it was skipped is exactly the shape of green build that certifies nothing, so a missing contract is a failure here.

구현은 그 구분을 만들 수 없다(138-... §8.2).

Set<MongoReplicaSetContract> executed = new LinkedHashSet<>();
for (MongoReplicaSetContract contract : MongoReplicaSetContract.all()) {
  executed.add(contract);                       // ← 루프가 무조건 채운다
  if (!contractRunner.test(contract)) { failures.add(...); }
}
Set<MongoReplicaSetContract> missing = new LinkedHashSet<>(MongoReplicaSetContract.all());
missing.removeAll(executed);                    // ← 항상 비어 있다
missing.forEach(contract -> failures.add(... + " (not executed)"));

executedall()과 언제나 같으므로 missing은 언제나 비고, (not executed) 항목은 어떤 입력으로도 생성되지 않는다. certified()executed.containsAll(all())(78행)도 마찬가지로 항상 참이다.

조건부 형제가 같은 testkit 안에 있다. MongoChaosGate.report()는 같은 일을 옳게 한다 — executed는 명시적 record(scenario, passed) 호출로만 채워지는 map이고, missingall()에서 기록되지 않은 것을 뺀 것이다. 그리고 그 test가 그것을 증명한다: aScenarioThatNeverRanIsAFailureRatherThanASilence는 13개 시나리오 중 하나만 기록하고 나머지가 (not executed)로 나타나는지 단언한다.

contract suite의 대응 test는 그렇게 하지 않는다. stableContractsRunOnEverySupportedLane은 모든 contract에 contract -> true를 주고 나서 report.executed()가 전부를 담는지 단언한다 — 구조상 참인 명제다.

판정: P2. 두 인증 lane(7.0/8.0)의 커버리지 주장이 무효다. 수정은 형제를 따르면 된다 — run(...)이 실행할 contract 집합을 인자로 받거나, runner가 실제로 호출된 것만 executed에 넣는 것.

95. P2 — release gate가 실제로 차단하는 것은 hermetic test 3개이고, mongo용 CI workflow는 없다

이 leaf는 릴리스 증거 장치를 정성껏 만들었다. MongoReleaseEvidenceVerifier는 exit code 대신 JUnit XML을 읽고, testsuite 이름이 contract의 클래스와 일치하는지 확인하고, 파일이 실행 시작 시각보다 오래됐으면 거부하고, 전부 skip된 lane을 거부한다. 그 근거도 정확하다.

A Gradle test task exits zero when it runs the tests and also when its selector matched a different test… So "sharded topology certified" was satisfied by a hermetic unit test whose name happened to contain Shard.

그런데 그 장치가 실제로 지키는 목록을 열어 보면(138-... §8.3c, src/config/mongodb/release-contracts.json):

id task class topology
blocking MONGO-REL-001 mongoStableContractTest MongoModuleBoundaryTest none
MONGO-REL-002 mongoStableContractTest MongoAdvancedRulesTest none
MONGO-REL-003 mongoStableContractTest MongoTransactionRetryCoordinatorTest none
experimental MONGO-REL-010 mongoShardedTest MongoShardedTopologyContractTest sharded
MONGO-REL-011 mongoAtlasTest MongoAtlasContractTest atlas
MONGO-REL-012 mongoKmsTest MongoKmsContractTest kms

차단 계약 셋 전부가 topology=none, 즉 컨테이너가 필요 없는 hermetic 클래스다. experimental 셋은 어느 build 파일에도 등록되지 않은 task를 가리킨다(grep mongoShardedTest build.gradle → 매치 0; 스크립트가 그 사실을 스스로 적는다: "registered by no build file"). 그리고 컨테이너가 필요한 여섯 lane — mongoReplicaSetTest·mongoFailoverTest·mongoMigrationTest·mongoCompatibilityTest·mongoSecurityIntegrationTest·mongoPerformanceTest — 은 차단 목록에 하나도 없다.

그 위에 CI가 얹히지 않는다. .github/workflows에 26개 workflow가 있고 mongo를 언급하는 것은 0개다(138-... §8.4b, grep 매치 없음). 형제 leaf인 JPA는 일곱 개를 갖는다 — jpa-pr, jpa-nightly, jpa-release, jpa-r2-evidence, 그리고 jpa-next-* 세 개의 전방 호환 workflow. 여섯 mongo lane은 전부 기본 test task에서 제외돼 있으므로(§8.4), 사람이 손으로 부르지 않으면 아무 때도 돌지 않는다.

판정: P2. 이것은 개별 코드 결함이 아니라 이 leaf의 검증 지형이다. 그리고 앞선 sub-scope들에서 찾은 것들 — §67의 change stream 소실, §75의 TLS 미적용, §85의 sharding 미완료, §56의 fence 계약 — 이 왜 살아남았는지를 설명한다: 그것들을 잡을 lane은 릴리스를 막지 않고 CI에서 돌지 않는다. 수정은 두 갈래다. (a) 컨테이너 lane 중 최소한 mongoReplicaSetTest·mongoMigrationTest·mongoSecurityIntegrationTest를 blocking contract로 승격하고, (b) JPA와 같은 형태의 workflow를 추가하는 것.

96. P3 — 소비자가 없는 fixture 셋

138-... §8.1의 소비자 계수에서 test·testkit 양쪽 모두 0인 타입이 셋이다.

타입 무엇을 위한 것인가
MongoAtlasLocalContainer Atlas Local 컨테이너 — search·vector 계약의 빠른 피드백용. MongoAtlasCapabilityContractSuite(report 타입)는 test 1곳에서 쓰이지만, 실제 컨테이너를 띄우는 곳은 없다
MongoChunkMigrationController 트래픽 중 청크 이동 — "production hits during a rebalance"를 재현하는 유일한 장치
MongoRoundTripContract Java → BSON → 서버 → raw BSON → Java 왕복. javadoc: "Half a round trip proves nothing… only the raw BSON in the middle shows it"

셋째가 가장 무겁다. MongoReleaseContract의 형제인 MongoReplicaSetContractGOLDEN_BSON을 열거된 계약으로 두는데, 그 계약을 실행하도록 만들어진 타입에 호출자가 없다. MongoBsonSnapshot·MongoBsonSnapshotAssert는 쓰이므로 정규형 단언은 존재하지만 서버를 통과하는 왕복은 돌지 않는다 — 그리고 그 차이가 정확히 이 클래스가 존재하는 이유다. P3.

97. Negative-space probes — sub-scope 11

  • 8.1 reachability: 33개 testkit 타입의 소비자를 계수. 셋이 0(§96). 나머지는 test 또는 testkit 안에서 사용됨.
  • 8.2 조건부 형제: 같은 testkit의 두 커버리지 gate 중 하나만 "실행되지 않음"을 표현할 수 있다(§94). 각자의 test가 그 차이를 그대로 반영한다.
  • 8.3 계약 목록의 소재: new MongoReleaseContract는 test에만 있고, 정본은 src/config/mongodb/release-contracts.json(§95). experimental 셋은 존재하지 않는 task를 가리키며 스크립트가 그 사실을 명시한다 — 정직한 기록이므로 결함이 아니라 confirmed.
  • 8.4 lane / CI drift: 여섯 lane 정의는 있고 CI workflow는 없다(§95). build.gradle:87의 "382 hermetic contract tests"는 §0에서 측정한 526과 어긋난다(sub-scope 01의 문서 drift 항목과 동일 사안).

98. Sub-scope 11 findings backlog

우선순위 finding reachability
P2 release gate의 차단 계약 3개가 전부 topology=none hermetic 클래스이고, 컨테이너가 필요한 여섯 lane은 차단 목록에도 CI에도 없다(mongo workflow 0개, JPA는 7개) 모든 릴리스
P2 MongoStableContractSuite(not executed) 분기와 certified()의 커버리지 검사가 구조적으로 도달 불가 — 형제 MongoChaosGate는 같은 일을 옳게 한다 7.0/8.0 인증 lane
P3 소비자 0인 fixture 셋: MongoRoundTripContract(GOLDEN_BSON 계약의 실행체), MongoAtlasLocalContainer, MongoChunkMigrationController 해당 계약을 실제로 돌리려는 시점
P3/기록 experimental_contracts가 가리키는 세 task(mongoShardedTest·mongoAtlasTest·mongoKmsTest)가 어느 build 파일에도 없다 — 스크립트가 명시적으로 기록하고 있어 은폐는 아니다 Advanced 승격 시점

99. Sub-scope 11 완료 조건

  • denominator 49 / 49 FULL_READ (138-... OWNED FILES)
  • reachability(33종 소비자 계수)·조건부형제·계약목록 소재·lane/CI drift 4종 probe 수행
  • 두 finding 모두 정적으로 결정 가능하여 실행 probe 불필요, 소스 미변경(git status --short = 0)

100. 모듈 원장 대조

§0의 denominator 497을 하위 범위 실측과 대조한다.

# 하위 범위 main test testkit perf 실측 근거
1 governance / build / root / autoconfigure 15 12 4 31 121·122
2 api/** 61 9 70 127
3 mapping+nativecap+geo 23 4 27 130
4 imperative+reactive 47 14 61 131
5 query+aggregation 22 7 29 132
6 transaction 20 7 27 133
7 schema+migration 49 9 58 134
8 changestream 21 5 26 135
9 security+failure+observation+client 30 14 44 136
10 advanced/** 65 10 75 137
11 testkit + 미배정 test + perf 13 35 1 49 138
합계 353 104 35 5 497
  • main 353 = 351 Java + 2 비-Java(§0). 실측 LOC 합계 22,927.
  • test 104, testkit 35(3,036 LOC), perf 1(194 LOC), 기타 4(build/config/docs).
  • unclassified 0, structural-only 0, excluded 0. 11개 하위 범위 모두 FULL_READ.

101. 모듈 findings 종합

우선순위 개수 항목
P1 3 §67 change stream pipeline의 high-water mark로 인한 조용한 영구 소실(실행 probe 3종) · §67의 두 번째 경로(실패 없이도 CLAIMED_ELSEWHERE 위치를 지나침) · §75 MongoClientSettingsFactory 미호출로 프로파일의 TLS·타임아웃·풀·Stable API가 driver에 도달하지 않음
P2 9 §42 aggregation executor의 collection registry·실행 scope 우회 · §49 transaction flag가 요구만 만들고 실행체 없음 · §56 recordApplied의 fence 계약 미구현(보호 역전) · §57 index diff가 두 필드만 비교 · §68 changeStreams 고정 false와 조립된 소비자의 불일치 · §85 sharding admin gateway 3/4 완료 불가 · §94 MongoStableContractSuite 커버리지 검사 도달 불가 · §95 release gate가 hermetic 3개만 차단하고 mongo CI workflow 0개
P3 / 기록 20 각 sub-scope의 backlog 표 참조

가장 자주 반복된 형태는 셋이다.

  1. 선언과 조립의 분리. 정책·값 객체는 완성돼 있고 그것을 driver나 실행 경로에 붙이는 한 줄이 없다(§41·§60·§75·§78). 이 leaf가 fork를 위한 템플릿이라는 성격 때문에 상당 부분은 의도된 것이지만, §75처럼 수리 코드 자체가 배선되지 않은 경우와 §49·§68처럼 flag와 실행체가 어긋난 경우는 다르다.
  2. 발화할 수 없는 guard. requireCorrectResumeOption(자기 자신과 비교, §69), MongoStableContractSuite(not executed)(§94), 과거의 requireDistinctCredentials(role을 지문에 섞어 항상 통과 — 이미 수리됨, §74). 이 저장소는 이 패턴을 여러 번 스스로 찾아 고쳤고, 남은 것들은 같은 계열이다.
  3. 문서가 코드보다 오래 산다. MongoPlatformSettings의 "zero beans"(§68), build.gradle의 "382 hermetic tests"(실측 526), FlamingockLockAdapter의 "resumable migrations만 거부"(§59), README의 API surface 318/324(실측 338/350). 반대로 MongoAdvancedEntryPoint·MongoAccessRules·MongoModuleBoundaryTest는 문서였던 주장을 실행 가능한 규칙으로 바꾼 사례다(§84·§93).

102. 모듈 완료 조건

  • denominator 497 / 497 FULL_READ — 11개 하위 범위 전부 COMPLETE(§100)
  • 하위 범위마다 §8.1~§8.4 네 종 negative-space probe 수행, 증거는 evidence/raw/121138a
  • 정적으로 결정 불가한 지점은 실행 probe로 확정: 124/124a(설정 바인딩), 129/129a(빈 타입 레지스트리 쓰기), 134a(migration fence·index diff·Flamingock lease), 135a(change stream 소실 3종), 136a(TLS 미적용), 137(sharding 4작업)
  • 임시 probe class는 모두 제거, 매 실행 후 git status --short = 0, mongoStableContractTest 재실행 green
  • 소스 미변경 — 문서화 작업만 수행

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 230개 (main 180 · test 41 · 기타 9).

src/adapter/outbound/persistence-mongo/build.gradle
src/config/architecture/modules.json                    (adapter-outbound-persistence-mongo 항목)

main:
  src/app-bootstrap/src/main/resources/application.yml
  src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoOptInAutoConfigurationImportFilter.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceSettings.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoRootAutoConfiguration.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedCapabilityFlags.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedCapabilityGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedEntryPoint.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionEvidence.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionGate.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/autoconfigure/MongoAdvancedConfiguration.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/autoconfigure/MongoAdvancedSettings.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoChangeMessagingBridge.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoPublishResult.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleClientFactory.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionCollectionManager.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionProfile.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsMigrationJob.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardKeyDescriptor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/MongoShardingAdminGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ReshardApproval.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyReadinessReport.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantClientRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantMigrationCoordinator.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/MongoTenantPredicateInjector.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/TenantScopedMongoOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesCapabilityValidator.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorSearchBenchmarkGate.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorSearchOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/PolicyAwareMongoAggregationExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/CollectionProfileName.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/DatabaseProfileName.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationContext.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationScope.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoServerVersion.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyDescriptor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyProfile.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDataSchemaUnsupportedException.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDocumentTooLargeException.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoExecutionOutcome.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureCategory.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureContext.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoOperationRejectedException.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoPersistenceException.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoRetryScope.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTimeoutException.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTransactionCommitUnknownException.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTransactionTransientException.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTypeRepresentationManifest.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/MongoOperationObservation.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/MongoOperationObserver.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/NoOpMongoOperationObserver.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/package-info.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/api/schema/MongoSchemaVersionPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoDriverObservabilityAutoConfiguration.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformAutoConfiguration.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformHealthIndicator.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformSettings.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoProfileProperties.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStartupValidator.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoTopologyProbe.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamPipeline.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamState.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamSubscription.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoClusterTime.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpoint.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpointStore.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeTokenCodec.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/consumer/ReactiveMongoChangeStreamConsumer.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/consumer/SpringReactiveChangeStreamSource.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeDeduplicationStore.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeProjectionResult.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeProjector.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeStreamRunner.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeHistoryLostException.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeStreamRecoveryPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoInvalidateRecovery.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/client/MongoClientSettingsFactory.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/DefaultMongoFailureTranslator.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoDriverFailureView.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureClassification.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureClassifier.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureExtractor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureTranslator.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoDistance.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoPoint.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoQuery.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/SpringMongoGeospatialOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/BoundScopedOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/DefaultMongoImperativeExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCollectionProfileRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCompletion.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoConsistencyBinder.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoPlatformCollectionAccess.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoTemplateSupportContract.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/ScopedMongoOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicOperationsTemplate.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicPolicyRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkResult.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/SpringDataBulkFailureExtractor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/VersionedMongoUpdater.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/BigDecimalToDecimal128Converter.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/BigIntegerRepresentationConverters.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/LocalDateTimeMappingGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoCustomConversionsFactory.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoMappingConfiguration.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoTypeMetadataConfigurer.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/MongoTypeMetadataRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/PolicyAwareMongoTypeMapper.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoCollectionMigrationLedger.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoCollectionMigrationLock.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigration.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationCheckpoint.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationHeartbeat.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLedger.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLock.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationRunner.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockLedgerAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockLockAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/ApprovedMongoNativeOperation.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeCapabilityGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeOperationPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/PolicyAwareMongoNativeGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MicrometerMongoOperationObserver.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoCommandObservationListener.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoDriverObservabilityConfiguration.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationConvention.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationRedactor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoOperator.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoQueryPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoRegexPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/query/PolicyAwareMongoQueryBuilder.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetEnforcer.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetPolicyRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoOperationBudget.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetCursorCodec.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetQueryBuilder.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/DefaultReactiveMongoExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoConsistencyBinder.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoContextKeys.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexApplyPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDescriptorView.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiff.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiffEngine.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexRetirementState.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoCollectionManifest.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoIndexManifest.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoManifestRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoMetadataOwnership.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/EmbeddedCollectionDescriptor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentModelValidator.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentSizeBudget.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlIndexDescriptor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicyValidator.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorApplyPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorDiffEngine.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoCredentialReference.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoCredentialResolver.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityProfileValidator.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminApproval.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuditRecord.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuthorization.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminCommand.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminOperation.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminRuntimeGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionProfile.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionScope.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringMongoTransactionSessionFactory.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringReactiveMongoTransactionExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoRetryBudget.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoTransactionRetryCoordinator.java
  src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/SpringMongoCausalSessionExecutor.java

test:
  src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoNamespaceContractTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfigTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardAwareQueryValidatorTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyAnalyzerTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureContextTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoAdvancedRulesTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoModuleBoundaryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStartupValidatorTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamPipelineTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/consumer/ChangeStreamConsumerLifecycleTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeStreamRunnerTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/client/MongoClientSettingsFactoryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoNetworkFaultLaneTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/PolicyAwareMongoTypeMapperTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationFencingTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLaneTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockMongoMigrationAdapterTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationConventionTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityIntegrationLaneTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/security/MongoTlsLaneTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuditStateMachineTest.java
  src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoTransactionRetryCoordinatorTest.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoRepositoryArchitectureRules.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/arch/MongoAccessRules.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/arch/MongoAdvancedRules.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/atlas/MongoAtlasCapabilityContractSuite.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/atlas/MongoAtlasLocalContainer.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoStableContractSuite.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoProxiedReplicaSetNode.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoThreeNodeReplicaSet.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/ToxiproxyMongoNetworkFaultController.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshot.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshotAssert.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoRoundTripContract.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoChaosGate.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/release/MongoReleaseContract.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/release/MongoReleaseEvidenceVerifier.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoAuthenticatedReplicaSetContainer.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoReplicaSetContract.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoSingleReplicaSetContainer.java
  src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/sharded/MongoChunkMigrationController.java

기타:
  CLAUDE.md
  README.md
  docs/architecture/mongo-api-surface.txt
  docs/mongodb/repository-adaptation.md
  docs/mongodb/runbooks/failover.md
  docs/mongodb/runbooks/history-lost.md
  docs/registries/env-keys.yaml
  src/build.gradle
  src/config/mongodb/release-contracts.json

해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등:
  evidence/raw/121-persistence-mongo-module-inventory.txt
  state.json
  evidence/raw/122-mongo-governance-optin-manifest.txt
  *.md
  evidence/raw/123-mongo-optin-reachability-and-siblings.txt
  application.yml
  evidence/raw/125-mongo-governance-doc-count-drift.txt
  126-mongo-hermetic-lane-original-verification.txt
  *.java
  evidence/raw/126-mongo-hermetic-lane-original-verification.txt
  evidence/raw/127-mongo-api-scope-manifest.txt
  evidence/raw/128-mongo-api-negative-space-probes.txt


A07. adapter-outbound-identifier

분석 중에는 07-adapter-outbound-identifier.md 파일이었다. 233줄.

07 · adapter-outbound-identifier

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-outbound-identifier
  • canonical state analysisFile: §A07 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/outbound/identifier · Gradle :adapter:outbound:identifier
  • registry allowed_dependencies: ["domain-core", "application-core"]
  • registry runtime_memberships: ["app-bootstrap", "sample-portfolio"]
  • coverage ledger: FULL_READ 10 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


상태: COMPLETE
revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
경로: src/adapter/outbound/identifier · Gradle: :adapter:outbound:identifier

0. Denominator와 coverage ledger

이 leaf는 tracked file 10개다. 하위 범위로 나눌 크기가 아니라 한 범위로 처리한다.

구분 파일 LOC 상태
governance (CLAUDE.md, README.md, build.gradle, gradle.lockfile) 4 297 FULL_READ
main Java (HmacUserPrincipalPseudonymizer, RandomUploadIdentifierFactory, UuidCodec, package-info) 4 114 FULL_READ
test Java (HmacUserPrincipalPseudonymizerTest) 1 112 FULL_READ
test Groovy (UuidCodecSpec) 1 39 FULL_READ
합계 10 562 10 / 10 FULL_READ

structural-only 0 · excluded 0 · unclassified 0.

manifest: evidence/raw/139-identifier-module-inventory.txt. probe(정적 + 실행): evidence/raw/140-identifier-negative-space-probes.txt.

레지스트리 항목:

{ "id": "adapter-outbound-identifier",
  "gradle_path": ":adapter:outbound:identifier",
  "allowed_dependencies": ["domain-core", "application-core"],
  "runtime_memberships": ["app-bootstrap", "sample-portfolio"] }

1. 이 모듈이 존재하는 이유

CLAUDE.md와 README가 같은 논거를 편다: 외부 시스템 연동이 없는(non-IO) 인프라 능력만 모아 두어, adapter-outbound가 문서화된 의미("외부 HTTP / messaging / cache / notifications")를 유지하게 한다는 것이다.

Kept out of adapter-outbound on purpose: a UUID id/codec capability is infrastructure, not an outbound integration point.

그 논거의 예시로 드는 능력이 UUID id/codec이다. §3에서 보듯 그 능력에는 production 소비자가 없고, 실제로 배선돼 도는 것은 나머지 둘(가명화, 업로드 식별자)이다.

2. Confirmed — HmacUserPrincipalPseudonymizer는 이 leaf에서 가장 잘 만들어진 부분이다

UserPrincipalPseudonymizerPort(application-core)의 유일한 구현이고, app-bootstrapsample-portfolio가 각자 싱글톤 빈으로 배선한다(140-... §8.1). 결정들이 코드와 문서 양쪽에 맞물려 있다.

  • thread safety를 타입이 아니라 수명으로 푼다. Mac은 thread-safe가 아니므로 pseudonymize 호출마다 새로 만든다 — 그래서 공유 싱글톤으로 안전하다. 주석이 그 이유를 그 자리에 적는다.
  • salt를 스스로 조달하지 않는다. APP_PRIVACY_PSEUDONYMIZATION_SALT에서 app-bootstrap이 공급하고(분류 secret, 90일 회전), 생성자는 null/빈 배열을 거부하며 거부 메시지가 그 환경변수 이름을 그대로 말한다.
  • 방어적 복사. salt.clone()으로 호출자 배열을 붙들지 않는다.
  • 도달 불가 예외를 정직하게 감싼다. HmacSHA256은 JCA 필수 알고리즘이라 NoSuchAlgorithmException·InvalidKeyException은 사실상 도달 불가이고, 호출부에 checked exception 잡음을 남기지 않으려 IllegalStateException으로 감싸며 주석이 "this should never happen on a compliant JDK"라고 적는다.
  • Spring-free. 어노테이션이 없고 빈 생성은 composition root 책임이다.

test 11개가 경계를 실제로 나눠 덮는다 — 생성자 가드 2, null/blank/empty 입력 3, 같은 salt의 결정성(같은 인스턴스·다른 인스턴스) 2, salt 민감도 1, 단방향성 2(출력≠입력, 출력이 입력을 부분문자열로 포함하지 않음), 출력 포맷 1(^[0-9a-f]{64}$). 값 하나를 고정하는 golden test가 아니라 성질을 검사한다.

RandomUploadIdentifierFactory의 판단도 기록해 둘 만하다. 파일 식별자가 공개 핸들이므로 시퀀스나 타임스탬프가 아니라 암호학적 난수에서 뽑는다고 적고, 그 대가까지 명시한다 — "A time-ordered identifier would be the better database key, and is deliberately not used: it would let anyone holding one id infer when neighbouring files were created and enumerate towards them." 결정과 그 결정이 포기한 것을 함께 적는 서술이다.

경계 규칙도 실재한다. CLAUDE.md가 이름을 대는 ArchUnit 규칙은 CleanArchitectureTest대문자 상수 IDENTIFIER_ADAPTER_DOES_NOT_DEPEND_ON_OTHER_ADAPTERS_OR_BOOTSTRAP로 존재하고, inbound web · persistence · bootstrap · Spring Data · JPA · Hibernate를 금지하며 형제 배제(..adapter.outbound.. 중 자기 패키지 제외)까지 처리한다(140-... §8.4e). CLAUDE.md의 표기가 snake_case일 뿐 가드는 진짜다 — confirmed match.

3. P2 — 모듈의 존재 논거인 UuidCodec에 production 소비자가 없다

140-... §8.1·§8.1b의 저장소 전수 검색 결과:

타입 leaf 밖 production 소비자
HmacUserPrincipalPseudonymizer app-bootstrap, sample-portfolio (각 1)
RandomUploadIdentifierFactory app-bootstrap (1)
UuidCodec 0

UuidCodec. 형태의 호출은 저장소 전체에서 자기 Spock 스펙 5줄뿐이다. 이름이 겹쳐 걸린 나머지 둘은 무관하다 — mongo testkit의 org.bson.codecs.UuidCodec(드라이버 타입)과 sample-portfolio/README.md:333의 산문 언급("UuidCodec 같은 공용…").

그 자리를 대신 채우고 있는 것들이 있다. UUID.fromString을 직접 부르는 파일이 leaf 밖에 20개 이상이고(graphql UuidScalar, notification JacksonRoutingPlanCodec, jpa PostgreSqlIdempotencyClaimRepository, application-core FileId/UploadId …), CLAUDE.md가 toUuid/fromUuid의 목적으로 든 **PostgreSQL uuid 컬럼 변환(D10)**은 실제로는 Hibernate의 @JdbcTypeCode(SqlTypes.UUID)가 처리한다(140-... §8.4b, JPA 엔티티 다수).

판정: P2. 코드 자체에는 결함이 없다 — 30줄짜리 유틸이고 자기 스펙을 통과한다. 문제는 §1의 논거다. 모듈을 adapter-outbound 밖에 두는 근거로 "UUID id/codec 능력"을 들고 있는데, 그 능력은 아무도 쓰지 않고 같은 일이 저장소 곳곳에서 각자 수행된다. 나머지 두 타입(가명화·업로드 식별자)만으로도 non-IO 능력 모듈의 논거는 성립하므로, 수정은 둘 중 하나다: UuidCodec을 실제 단일 경로로 만들거나(그러면 §4가 먼저 고쳐져야 한다), 모듈의 논거에서 빼는 것.

4. P2 — normalize는 canonical이 아닌 입력을 받아 다른 UUID로 조용히 바꾼다

UuidCodec.normalize의 계약은 Javadoc과 README 양쪽에 적혀 있다.

Accepts a case-insensitive canonical UUID string and returns the canonical 36-character lowercase form… @throws IllegalArgumentException on a malformed UUID. (README) 형식 오류 UUID 에는 IllegalArgumentException.

구현은 UUID.fromString(input).toString() 한 줄이다. JDK의 UUID.fromString은 길이 36 fast path 밖에서 대시로 나뉜 5개 hex 그룹을 길이 검사 없이 받는다. 실행 probe로 확인했다(140-... EXECUTION PROBE).

PROBE normalize("0190BD6E-7C3E-7ABC-8DEF-0123456789AB") -> "0190bd6e-7c3e-7abc-8def-0123456789ab"   ← 의도된 동작
PROBE normalize("1-1-1-1-1")  -> "00000001-0001-0001-0001-000000000001"
PROBE normalize("0-0-0-0-0")  -> "00000000-0000-0000-0000-000000000000"
PROBE normalize("1-2-3-4-5")  -> "00000001-0002-0003-0004-000000000005"
PROBE normalize("0190bd6e7c3e7abc8def0123456789ab") -> IllegalArgumentException
PROBE normalize("not-a-uuid")                       -> IllegalArgumentException

"1-1-1-1-1"은 canonical UUID가 아니다. 계약대로면 IllegalArgumentException이어야 하는데, 수용된 뒤 다른 문자열로 재작성되어 반환된다. 결과적으로 서로 다른 두 입력("1-1-1-1-1""00000001-0001-0001-0001-000000000001")이 같은 식별자로 정규화되고, 거부됐어야 할 값이 정상적으로 보이는 id가 된다. normalize는 D3 — 호출자가 준 텍스트를 저장 형태로 바꾸는 지점 — 이므로, 관대함이 남는 위치가 하필 신뢰 경계다.

기존 스펙이 이것을 놓친 이유도 코드에 있다. UuidCodecSpec의 거부 케이스는 "not-a-uuid" 하나이고, 그 문자열은 대시 그룹이 5개가 아니라 관대한 경로에 닿지 않는다.

도달성. 지금 이 메서드를 부르는 production 코드는 없다(§3). 그래서 현재 노출은 0이고, UuidCodec을 단일 경로로 승격하는 순간 결함이 된다. 판정: P2. 수정은 input.length() != 36이거나 대시 위치가 8-13-18-23이 아니면 먼저 거부하는 것 — 또는 계약 문구를 실제 동작(JDK 관대 파싱)에 맞추는 것이다. 전자가 문서가 말하는 바다.

5. P2 — 문서는 UUIDv7이라고 말하고, 생성되는 것은 v4다

CLAUDE.md:21과 README:19가 같은 문장을 쓴다 — UuidCodec은 "JDK java.util.UUID (RFC 9562 UUIDv7)" 위에서 동작한다. README:12도 이 모듈의 능력을 "식별자 생성/인코딩(UUIDv7)"이라 적는다. 그리고 이 주장은 leaf 밖으로도 번져 있다 — CleanArchitectureTest가 이 패키지를 설명하는 세 곳(:948, :981, :1310)이 전부 "UUIDv7 id/event-id generation & codec"이라고 쓴다.

실행 probe(140-...):

PROBE newFileId   version=4 variant=2
PROBE newUploadId version=4 variant=2
PROBE UUID.randomUUID() version=4   (RFC 9562 UUIDv7 would report version=7)

java.util.UUID에는 v7 생성기가 없고, 이 leaf의 유일한 생성기 RandomUploadIdentifierFactoryUUID.randomUUID()(v4)를 쓴다. 더 분명한 것은 그 클래스의 javadoc이 v7을 명시적으로 거부한다는 점이다 — "A time-ordered identifier would be the better database key, and is deliberately not used." 즉 코드는 숙고된 결정을 내렸고, 모듈 문서와 아키텍처 test의 설명문이 정반대 결정을 서술한다.

저장소의 진짜 UUIDv7은 다른 곳에 있다: sample-portfoliocom.github.f4b6a3:uuid-creator를 자기 build.gradle에 선언하고 UuidCreator.getTimeOrderedEpochPlus1()을 쓴다(SampleOperationStore:27).

판정: P2. 런타임 동작은 옳다 — 잘못된 것은 세 문서와 한 test의 설명문이다. 그러나 "이 모듈이 UUIDv7을 제공한다"는 서술은 fork가 시간정렬 키를 기대하게 만들고, 그 기대는 인덱스 지역성과 열거 가능성 양쪽에서 반대 방향의 결과를 낳는다.

6. P3 — CLAUDE.md의 의존성 서술이 세 항목 모두 틀렸다

CLAUDE.md:3133:

:application-code, :domain-core, :shared-contract (Gradle matrix). Currently only :domain-core + com.github.f4b6a3:uuid-creator are declared in build.gradle.

build.gradle의 실제 dependencies 블록은 두 줄이다.

implementation project(':application-core')
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
  • :domain-core — 선언돼 있지 않다.
  • uuid-creator — 선언돼 있지 않다(저장소 검색상 이 leaf의 classpath에 없다; sample-portfolioapp-bootstrap의 lockfile에만 있다).
  • :application-core — 선언돼 있는데 문장은 언급하지 않는다.

레지스트리의 allowed_dependencies(domain-core, application-core)와 실제 선언(부분집합)은 정합한다. 어긋난 것은 CLAUDE.md의 서술뿐이다. P3.

7. P3 — README의 세 가지 사실 오류

README 실제
:3 패키지 루트 dev.caskeleton.adapter.identifier dev.caskeleton.adapter.outbound.identifier (CLAUDE.md:11은 정확)
:59 "Spock 2.4 / Groovy 4.0 variant" spock-core:2.4-groovy-**5.0**
:64 edge는 src/build.gradleallowedProjectDependencies['**adapter-identifier**']로 허용 build.gradle:1416allowedProjectDependencies는 리터럴 맵이 아니라 registry.modules.collectEntries { … }레지스트리에서 파생되며, 이 모듈의 키는 adapter-outbound-identifier

셋 다 메커니즘 자체는 실재하고 동작한다 — 틀린 것은 이름과 버전이다. P3.

8. P3 — CLAUDE.md가 대는 두 가드 중 하나는 저장소에 없다

CLAUDE.md:3839가 금지 사항의 근거로 둘을 든다.

  1. ArchUnit identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap존재한다(§2, 상수명은 대문자). confirmed.
  2. .claude/hooks/ca_import_gate.py G4가 쓰기 시점에 차단.claude/ 디렉터리에는 settings.local.json 하나뿐이고 hooks/ 하위 디렉터리도 ca_import_gate.pytracked 되어 있지 않다(140-... §8.4e).

개발자 머신에 로컬로 존재할 여지는 있으나, 저장소를 새로 clone한 사람에게 그 가드는 없다. "쓰기 시점에 차단된다"는 서술은 clone에서 성립하지 않는다. P3.

9. P3/기록 — 결정 SSOT가 이 revision에서 해석되지 않는다

CLAUDE.md와 README가 UuidCodec의 동작을 D3(normalize)와 D10(toUuid/fromUuid)로 지목하고, 모듈 분류 근거로 "feature-resource-identifier-contract §4 taxonomy"를 든다. CleanArchitectureTest도 같은 문서를 §4·D5·D9·D17로 네 곳에서 인용한다.

그 문서는 이 revision에 파일로 존재하지 않는다(find -iname '*resource-identifier*' 매치 0; docs/에서 걸리는 D3/D10은 전부 MongoDB의 무관한 노출 평면 표기다). CleanArchitectureTest:2432의 주석이 이유를 밝힌다 — "decision SSOT: resource-identifier branch". 즉 다른 브랜치에 있다.

은폐가 아니라 명시된 상태이므로 결함으로 올리지 않고 기록한다. 다만 이 leaf의 문서가 자기 동작의 근거로 대는 결정 ID들은 이 브랜치만 읽어서는 확인할 수 없고, §4·§5의 어긋남이 "구현이 결정을 벗어난 것"인지 "결정이 그 사이 바뀐 것"인지도 여기서는 판정 불가다. P3/기록.

10. Negative-space probes

  • 8.1 public-surface reachability: 세 production 타입의 저장소 전수 소비자 계수. UuidCodec 0(§3), 나머지 둘은 composition root가 배선.
  • 8.2 조건부 형제 비교: UuidCodec의 세 자매 메서드가 null을 다르게 다룬다 — normalize(null)null(문서화됨), toUuid(null)NullPointerException, fromUuid(null)NullPointerException(둘 다 미문서화). 실행 probe로 확인. 계약 문구는 normalize에만 있다. P3.
  • 8.2b 계약 ↔ 구현 대조: normalize의 "canonical만 수용" 주장과 JDK 관대 파싱(§4). 실행 probe로 확정.
  • 8.3 중복 mechanism sweep: UUID 문자열 변환이 leaf 밖 20+ 파일에서 UUID.fromString으로 각자 수행되고, D10이 지목한 PostgreSQL uuid 컬럼 변환은 Hibernate @JdbcTypeCode(SqlTypes.UUID)가 담당(§3). 저장소의 UUIDv7 생성은 sample-portfolioUuidCreator(§5).
  • 8.4 문서/개수 drift: §5(UUID 버전, leaf 문서 3곳 + 아키텍처 test 3곳) · §6(의존성 서술 3항목) · §7(패키지 루트·Groovy 버전·설정 키) · §8(가드 하나 부재) · §9(결정 SSOT 미해석).

11. Findings backlog

우선순위 finding reachability
P2 UuidCodec.normalize가 canonical이 아닌 5-그룹 입력("1-1-1-1-1" 등)을 수용해 다른 UUID로 재작성한다 — Javadoc·README는 canonical만 받고 malformed는 거부한다고 적는다 (실행 probe) 현재 호출자 0; UuidCodec을 단일 경로로 쓰는 순간 신뢰 경계 결함
P2 UuidCodec에 production 소비자가 0인데, 모듈을 adapter-outbound 밖에 두는 논거가 바로 이 "UUID id/codec 능력"이다. 같은 변환이 leaf 밖 20+ 파일에서 각자 수행되고 D10의 대상은 Hibernate가 처리한다 문서/모듈 경계 논거
P2 leaf 문서 3곳과 CleanArchitectureTest 설명문 3곳이 "RFC 9562 UUIDv7"이라 적지만 유일한 생성기는 v4를 만들고, 그 클래스의 javadoc은 시간정렬 id를 명시적으로 거부한다 (실행 probe: version=4) fork가 시간정렬 키를 기대하는 경우
P3 CLAUDE.md:3133의 build.gradle 선언 서술이 세 항목 모두 사실과 다르다 문서
P3 README의 패키지 루트·Spock/Groovy variant·allowedProjectDependencies 키 3건 오류 문서
P3 CLAUDE.md가 대는 write-time 가드 .claude/hooks/ca_import_gate.py가 저장소에 tracked 되어 있지 않다 새 clone
P3 UuidCodec 세 자매 메서드의 null 처리 비대칭이 normalize에만 문서화돼 있다 호출 시점
P3/기록 결정 SSOT feature-resource-identifier-contract(§4·D3·D5·D9·D10·D17)가 다른 브랜치에 있어 이 revision에서 해석되지 않는다 결정 대조

12. 완료 조건

  • denominator 10 / 10 FULL_READ — structural-only 0 · excluded 0 · unclassified 0 (§0)
  • §8.1(공개 표면 도달성) · §8.2(조건부 형제) · §8.3(중복 mechanism) · §8.4(문서/개수 drift) 네 종 probe 수행
  • 정적으로 결정 불가한 세 지점(normalize의 실제 수용 범위, 세 자매의 null 처리, 생성되는 UUID 버전)을 실행 probe로 확정(140-...)
  • CLAUDE.md가 대는 두 가드를 각각 추적해 하나는 실재(confirmed), 하나는 부재로 분리 판정 — 이름 표기 차이를 결함으로 올리지 않았다
  • 임시 probe class 1개 추가 후 제거, git status --short = 0, 소스 미변경

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 8개 (main 3 · test 1 · 기타 4).

src/adapter/outbound/identifier/build.gradle
src/config/architecture/modules.json                    (adapter-outbound-identifier 항목)

main:
  src/main/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizer.java
  src/main/java/dev/caskeleton/adapter/outbound/identifier/RandomUploadIdentifierFactory.java
  src/main/java/dev/caskeleton/adapter/outbound/identifier/UuidCodec.java

test:
  src/test/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizerTest.java

기타:
  CLAUDE.md
  README.md
  src/build.gradle
  src/sample-portfolio/README.md

해석되지 않은 인용 (3종) — 외부 타입·문서상 약칭 등:
  evidence/raw/139-identifier-module-inventory.txt
  evidence/raw/140-identifier-negative-space-probes.txt
  settings.local.json


A08. adapter-outbound-fileserver

분석 중에는 08-adapter-outbound-fileserver.md 파일이었다. 768줄.

08 · adapter-outbound-fileserver

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-outbound-fileserver
  • canonical state analysisFile: §A08 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/outbound/fileserver · Gradle :adapter:outbound:fileserver
  • registry allowed_dependencies: ["application-core", "shared-contract"]
  • registry runtime_memberships: ["app-bootstrap"]
  • coverage ledger: FULL_READ 119 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행) — EVD-334의 로케일 finding이 이 리프의 것이다

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


상태: COMPLETE
revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
경로: src/adapter/outbound/fileserver · Gradle: :adapter:outbound:fileserver

0. Denominator와 coverage ledger

tracked file 119개 — main 78 (12,707 LOC), test 37 (12,043 LOC), governance 4. 총 약 24.7k LOC. build.gradle에 별도 source set이나 test lane 선언이 없다(main/test뿐).

레지스트리:

{ "id": "adapter-outbound-fileserver",
  "gradle_path": ":adapter:outbound:fileserver",
  "allowed_dependencies": ["application-core", "shared-contract"],
  "runtime_memberships": ["app-bootstrap"] }

leaf 밖 소비자는 app-bootstrap 하나다 — CaSkeletonApplication + autoconfigure/fileserver/** 6개 config 클래스, 그리고 test 3개.

패키지 배치(main): 루트 fileserver 31 · platform/local 33 · platform/verification 10 · platform/security 2 · platform/audit 2.

하위 범위 원장
# 범위 main test 상태
1 governance / build / config / activation (+ governance 4) 7 2 13 COMPLETE
2 control plane + control record codec + recovery verifier 3 3 6 COMPLETE
3 publication — provider · adapter · journal · attestor · binding 19 7 26 COMPLETE
4 platform/local IO primitive · gateway · store/publisher 22 8 30 COMPLETE
5 platform/local failure·probe·health·orphan + verification + security + audit 24 5 29 COMPLETE
6 payload operations · CSV export · testkit 계약 · crash matrix 3 12 15 COMPLETE
TOTAL 78 37 119 (governance 4 포함) 6 / 6

manifest: evidence/raw/141-fileserver-module-inventory.txt.


1. Sub-scope 01 범위와 denominator

내부 상태: COMPLETE — 13 / 13 FULL_READ 범위: governance 4 + config/activation main 7 + 전용 test 2 역할: R1(CSV export)과 R2(local-persistent publication) 두 개의 opt-in 선택자를 서로 혼동될 수 없게 분리하고, 잘못된 조합을 파일시스템에 손대기 전에 거부한다

manifest와 probe: evidence/raw/142-fileserver-config-activation-probes.txt.

2. 선택자 세 개가 각자 다른 것을 켠다

이 leaf는 이름이 비슷한 세 능력을 명시적으로 갈라 둔다(CLAUDE.md).

namespace 무엇을 켜는가 소유
app.fileserver.* R2 publication (local-persistent) 이 leaf
app.file-export.* R1 CSV export (+ legacy-enabled로 덮어쓰기 가능 legacy port) 이 leaf
app.fileserver-platform.* HTTP Fileserver 플랫폼(업로드/다운로드/수명주기 라우트) app-bootstrap

셋 다 기본 off이고, R1과 R2 동시 활성화는 파일시스템 초기화 전에 실패한다. FileserverActivationValidator.rejectAmbiguous(environment)가 세 bean factory 메서드의 첫 줄에서 호출되고(FileExportConfig:33·:49, FileserverR2Config:31), Binder로 두 selector를 직접 읽으므로 bean 정의 순서에 의존하지 않는다. test가 그 순서를 고정한다 — enablingLegacyR1AndR2TogetherFailsBeforeEitherFilesystemIsMutated는 실패 후 R2 루트의 .ca-fileserver·data와 R1/legacy 루트가 모두 존재하지 않음을 단언한다.

R2 쪽 조립은 fail-closed가 촘촘하다. FileserverR2Config.routingFilePublicationPort는 destination을 컴파일하고, 서로 다른 provider ID가 같은 루트를 소유하는 조합을 거부하고(rejectSharedRootAcrossProviderIds), provider ID별로 하나의 attestor/control plane/payload 런타임을 만들어 같은 provider를 지목한 모든 destination이 공유하게 한다. test 둘이 그 공유/분리를 각각 확인한다(destinationsBoundToOneProviderReuseOneProviderRuntime, destinationsBoundToDifferentProvidersUseDifferentProviderRuntimes).

FileserverR2Validation은 값 검증을 한곳에 모은다 — ID는 [a-z][a-z0-9-]{0,62}이고 이미 정규화돼 있어야 하며, 경로는 이미 절대·정규화돼 있어야 하고, sentinel 이름은 ./../구분자/제어문자를 거부한 뒤 UTF-8 인코딩 길이 255바이트와 getNameCount()==1까지 확인한다. maximum-root-mode는 네 자리 8진수만 받고 group/world write를 별도로 거부한다((group & 2) != 0 || (others & 2) != 0).

3. Confirmed — 비활성 상태에서 부작용이 없다는 것을 test가 실제로 확인한다

disabledR2CreatesNoPortOrFilesystemSideEffect는 bean 부재만이 아니라 설정된 루트가 생성되지 않았음(assertThat(absentRoot).doesNotExist())까지 단언한다. unknownConfigurationFieldIsRejectedInsteadOfSilentlyIgnored도 실패 후 .ca-fileserver·data 부재를 확인한다. "비활성이면 아무 일도 없다"를 bean 목록이 아니라 파일시스템으로 검증하는 형태다.

rejectsLegacyRootThatAliasesThePublicationRootThroughASymbolicLink는 심볼릭 링크로 우회한 루트 겹침까지 본다 — canonicalDirectorytoRealPath()로 정규화한 뒤 startsWith로 양방향 포함을 검사하기 때문에 잡힌다.

4. P2 — README가 "노출된 setting도 bean도 없다"고 적은 능력들에 production bean이 있다

README:103105의 guarantee boundary 문단이 이렇게 끝난다.

Cross-node producer fencing, background reconciliation/reaping, retention, quota/backpressure, readiness/health, metrics, tracing, and audit are also not implemented. No setting or bean for those capabilities is exposed.

app-bootstrap이 그중 넷에 대해 이 leaf의 타입으로 bean을 만든다(142-... §8.4f).

README가 "없다"고 한 것 실제 bean 만드는 곳
audit StructuredAdminAuditAdapter, StructuredFileserverAuditAdapter FileserverSecurityConfiguration:71·:77
readiness/health LocalStorageHealthAdapter FileserverStorageConfiguration:179
background reconciliation/reaping LocalOrphanScanAdapter, LocalReconciliationContentProbe FileserverStorageConfiguration:189·:203
quota LocalStorageUsageProbe FileserverStorageConfiguration:196

공정하게 볼 지점. 코드 배치 자체는 앞뒤가 맞는다. 이것들은 R2 publication이 아니라 HTTP Fileserver 플랫폼(app.fileserver-platform.*, CLAUDE.md가 "owned by app-bootstrap"이라 적는 별개 능력)의 부품이고, build.gradle의 description도 이 leaf가 "the local filesystem content platform behind the HTTP Fileserver"를 함께 담는다고 밝힌다. main 78개 중 **67개가 platform/****라는 사실이 그 비중을 보여 준다.

잘못된 것은 문단의 범위다. "No setting or bean for those capabilities is exposed"에는 한정어가 없고, 이 문단은 독자가 이 모듈이 무엇을 제공하고 무엇을 제공하지 않는지 확인하러 오는 자리다. 그 자리에서 "audit은 구현돼 있지 않다"를 읽은 사람은 감사 기록이 없다고 결론짓는데, 같은 저장소가 두 개의 audit adapter를 bean으로 만든다. 판정: P2. 수정은 문단을 R2 publication 범위로 한정하고, 같은 leaf가 담는 플랫폼 부품이 별도 namespace로 조립된다는 사실을 그 자리에 적는 것이다.

(이 finding의 나머지 절반 — 그 bean들이 실제로 무엇을 보장하는가 — 은 platform/**을 읽는 sub-scope 05에서 다룬다.)

5. P3 — R1과 R2의 설정 취급이 비대칭이고, 검증된 쪽은 하나뿐이다

같은 leaf 안의 두 selector가 설정을 다르게 다룬다.

R2 app.fileserver.* R1 app.file-export.*
바인딩 타입 record + ignoreUnknownFields = false 가변 JavaBean, 기본값(미지의 키 무시)
루트 경로 requireAbsoluteNormalizedPath — 이미 절대·정규화여야 함 Path.of(v).toAbsolutePath().normalize() — 상대 경로 허용, CWD 기준 절대화
기본 루트 없음(필수) ./.data/fileserver, ./.data/fileserver-legacy
디렉터리 생성 하지 않음(attestation이 별도로 요구) Files.createDirectories(root)생성
미지 키 test unknownConfigurationFieldIsRejectedInsteadOfSilentlyIgnored 없음

R2에서는 strict-path-securty 같은 오타가 컨텍스트를 실패시킨다. R1에서는 app.file-export.maximum-rowz=10 같은 오타가 조용히 무시되고, 설정했다고 믿는 상한이 적용되지 않은 채 기본값 1,000,000이 쓰인다. 두 selector가 같은 leaf의 같은 성격 설정인데 한쪽만 fail-closed다. P3 — R1은 문서상 "compatibility only"이므로 우선순위를 낮춘다.

6. P3 — 문서가 지목한 기본값 위치와 test 목록이 실제와 다르다

  • README는 R2 selector가 "app-bootstrap/application.yml에서 false로 기본값을 갖는다"고 적는다. 그 파일에 app.fileserver.enabledapp.file-export.enabled없다(142-... §8.4e; app.fileserver로 걸리는 두 줄은 주석이다). 실효 기본값은 "속성 부재 → @ConditionalOnProperty 미매치 → bean 없음"이고 동작은 옳지만, 문서가 가리킨 자리에는 그 키가 없다.
  • README의 Tests 목록 첫 항목 FilePublicationContractTest는 이 leaf가 아니라 application-core에 있다.

7. Confirmed — 적재 경로는 auto-configuration이 아니라 명시적 component scan이다

이 leaf에는 META-INF/spring/…AutoConfiguration.imports없다(142-... §8.1). FileExportConfig/FileserverR2Config를 leaf 밖에서 이름으로 참조하는 production 코드도 없고, 유일한 외부 참조는 app-bootstrap의 test(OptionalAdapterBeanGatingTest)다.

실제 적재는 CaSkeletonApplication의 명시적 @ComponentScandev.caskeleton.adapter.outbound.fileserver를 목록에 올려서 이루어진다(:75). 즉 CLAUDE.md의 "never activates unexpectedly when merely present on the classpath"는 classpath 존재만으로 bean이 생기지 않는다는 뜻으로는 정확하지만, 기전은 import filter가 아니라 "@Configuration은 스캔되고 bean 생성만 @ConditionalOnProperty로 막힌다"이다. fail-closed는 성립한다 — 기록해 두는 이유는 mongo leaf의 4중 opt-in(§sub-scope 01, 06번 문서)과 기전이 다르기 때문이다.

8. Negative-space probes — sub-scope 01

  • 8.1 reachability: auto-configuration 등록 metadata 0, 적재는 명시적 component scan(§7). 세 bean 모두 @ConditionalOnProperty 게이트.
  • 8.2 조건부 형제: R1 vs R2의 설정 엄격도·경로 규칙·디렉터리 생성·test 커버리지 비대칭(§5).
  • 8.3 중복 mechanism: rejectAmbiguous 호출 3곳은 중복이 아니라 각 진입점의 첫 줄이라는 배치다 — Binder로 환경을 직접 읽으므로 bean 순서에 무관하고, app-bootstrap의 FileserverStartupValidator는 R1/R2 selector가 아니라 플랫폼 저장소 probe 결과를 검증하는 별개 장치다(중복 아님).
  • 8.4 문서/개수 drift: §4(가장 무거움) · §6(기본값 위치, test 목록).

9. Sub-scope 01 findings backlog

우선순위 finding reachability
P2 README:105 "No setting or bean for those capabilities is exposed"가 audit·health·reconciliation/reaping·quota 넷에 대해 사실과 다르다 — 모두 app-bootstrap이 이 leaf의 타입으로 bean을 만든다 이 문단을 근거로 능력 유무를 판단하는 독자
P3 FileExportSettingsignoreUnknownFields=false가 없어 app.file-export.* 오타가 조용히 무시된다(R2는 거부하고 test도 있다) R1을 켠 배포의 설정 오타
P3 R1 루트는 상대 경로를 허용해 CWD 기준으로 절대화하고 디렉터리를 생성하는데, R2는 이미 절대·정규화된 경로만 받는다 — 같은 leaf의 두 selector가 다른 규칙 R1 배포
P3 README가 지목한 selector 기본값 위치(app-bootstrap/application.yml)에 해당 키가 없고, Tests 목록의 FilePublicationContractTestapplication-core 소속이다 문서

10. Sub-scope 01 완료 조건

  • denominator 13 / 13 FULL_READ (142-... OWNED FILES)
  • §8.1(적재 경로)·§8.2(R1/R2 형제)·§8.3(중복 아님 확인)·§8.4(문서 drift) 네 종 probe 수행
  • §4는 app-bootstrap의 bean 생성 지점을 직접 확인해 판정했고, 그 bean들이 무엇을 보장하는지는 sub-scope 05로 이월
  • 소스 미변경

11. Sub-scope 02 범위와 denominator

내부 상태: COMPLETE — 6 / 6 FULL_READ 범위: LocalPersistentControlPlane 1,470 + FileserverControlRecordCodec 855 + LocalPersistentRecoveryVerifier 210 (main 3, 2,535 LOC) + 전용 test 3 (3,240 LOC) 역할: R2의 강제된(forced) 제어 평면 — 세 종류의 canonical 제어 레코드를 저장·검증하고, 상태 전이를 인접 행렬로 강제하며, 협력 프로세스를 JVM+OS 락으로 직렬화한다

manifest와 probe: evidence/raw/143-fileserver-control-plane-probes.txt.

test/main 비율이 1.28이다. 이 sub-scope에서 찾은 결함은 없고, 아래는 왜 없는지에 대한 기록이다.

12. Confirmed — codec이 "canonical"을 왕복으로 강제한다

FileserverControlRecordCodec은 세 레코드와 receipt snapshot에 대해 decode 직후 재encode해 바이트를 비교한다(requireCanonical(bytes, encodeOperation(record))). 그래서 "파싱은 되지만 우리가 쓰지 않았을 형태"가 전부 거부된다 — 공백, 필드 재배열, A 같은 이스케이프, -0/선행 0 같은 숫자 표기, 후행 콘텐츠. 파서 자체도 좁다.

  • 필드 집합을 정확히 일치시킨다(values.keySet().equals(allowedFields)) — 누락도 미지 필드도 거부.
  • 중복 키를 거부한다(putIfAbsent).
  • UTF-8 디코딩이 REPORT 모드라 malformed 바이트가 대체문자로 조용히 바뀌지 않는다.
  • \b \f \n \r \t 이스케이프를 문법 수준에서 거부한다("control characters are forbidden") — 제어문자가 이스케이프로 밀입되는 경로를 닫는다.
  • 짝 없는 서로게이트를 거부한다(requireWellFormedUnicode).
  • Instant.parseresult.toString().equals(value)canonical UTC 표기만 받는다.
  • receipt snapshot은 rsv1. 접두사 + unpadded base64url이고, 디코딩 후 재인코딩 문자열 비교로 alias(후행 비트가 0이 아닌 변형)를 거부한다.

test가 그 하나하나를 이름으로 고정한다 — canonicalDecoderRejectsWhitespaceReorderingEscapesNumbersUtf8AndTrailingContent, receiptSnapshotRejectsBase64urlAliasWithNonZeroTrailingBits, canonicalCodecRoundTripsSupplementaryUnicodeInOpaqueText, formulaMitigationCountCannotExceedCellsAndUsesOverflowSafeBounds.

마지막 것은 코드에서도 확인된다. requireFormulaCountWithinCellsrowCount * columnCount 곱을 하기 전에 rowCount <= Long.MAX_VALUE / columnCount를 먼저 본다 — 오버플로가 상한 검사를 무력화하는 경로를 닫는다.

13. Confirmed — 상태 전이가 인접 행렬이고 terminal이 진짜 terminal이다

validateOperationTransition이 여섯 가지를 순서대로 강제한다: requestFingerprint 불변 → 불변 identity 10개 필드 불변 → stateRevision 감소 금지 → 동일 revision 다른 내용 금지 → 정확히 +1 증가 → 인접 전이 행렬. 행렬은 README가 적은 사슬과 일치하고, 모든 비terminal 상태에서 QUARANTINED로만 이탈할 수 있으며 PUBLISHED·QUARANTINED는 후속 전이가 없다(case PUBLISHED, QUARANTINED -> false).

봉인 이후 사실은 얼어붙는다 — requireSealedFactsUnchanged가 byteSize·rowCount·columnCount·sha256·formulaMitigatedCount·sealedAt을 고정하고, MANIFEST_PUBLISHED 이후에는 manifestDigest, REFERENCE_PUBLISHED 이후에는 referenceDigest도 고정된다.

current.equals(candidate)는 전이가 아니라 **복구(repair)**로 취급된다 — 부모 디렉터리를 다시 force하고 정확 read-back만 수행한다. 크래시 후 같은 레코드를 다시 쓰는 재시도가 conflict가 되지 않게 하는 처리이고, parentForcedCallbackFailureIsRepairedByIdenticalOperationRetry가 이를 고정한다.

14. Confirmed — 두 개의 락 형태가 각자의 쓰기 원시연산에 맞춰져 있다

한 클래스 안에 락이 두 종류다. 얼핏 비대칭으로 보이지만 각자의 커밋 방식이 다르다.

operation 레코드 manifest / reference 레코드
커밋 원시연산 Files.move(ATOMIC_MOVE, REPLACE_EXISTING)배타적이지 않음 Files.createLink — 이미 있으면 FileAlreadyExistsException, OS 수준 배타
JVM 락 OPERATION_LOCK_STRIPES, 키에 root 범위 포함(operationLockRootKey + "\0" + token) IMMUTABLE_LOCK_STRIPES, 키는 "manifest:"+fileId — root 범위 없음
OS 락 FileChannel.lock() (.lock 파일, 0600, 소유자·FileStore 검증) 없음

즉 배타성이 필요한 쪽(replace)에는 OS 락을 두고, 원시연산 자체가 배타적인 쪽(create-link)에는 JVM 스트라이프만 둔 것이다. 후자의 root 미포함은 과잉 직렬화 방향이라(다른 root의 같은 fileId가 같은 스트라이프를 공유) 배타 누락으로는 이어지지 않고, fileIdSecureRandom 16바이트라 실질 충돌도 없다. 결함이 아니라 설계로 기록한다.

collision 경로도 닫혀 있다 — createLink가 충돌하면 임시 파일을 정확히 지우고, 기존 레코드를 읽어 identity와 내용 동등성을 확인한 뒤 같으면 repair, 다르면 CONFLICT다. concurrentCrossInstanceImmutableCollisionIsNeverClassifiedAsStorage가 그 분류를 고정한다.

15. Confirmed — poisoning은 root 범위이고, 읽기를 막지 않는 것이 의도다

OS 언락을 증명하지 못한 경우에만 POISONED_OPERATION_LOCK_ROOTS에 root 키가 들어간다. release와 close 중 하나라도 성공하면 poison하지 않는다(releaseProvedUnlock || closeProvedUnlock).

requireOperationLockRootHealthy()는 6곳에서 호출되는데 전부 쓰기 경로(storeOperation·acquireOperationLock×storeManifest·storeReference)이고, findOperation/findStoredOperation/findManifest/findReference 어디에도 없다. 처음에는 누락으로 보였으나 test 이름이 그것이 의도임을 못박는다 — poisonedRootBlocksEveryWriteIncludingHeldLockFastPathButAllowsReads. 이미 획득한 락의 fast path(heldTokens.contains(...))조차 poison에 걸린다는 것까지 이름에 들어 있다.

poison을 해제하는 경로는 없다(집합은 static이고 제거 호출이 없다). 프로세스 수명 동안 그 root는 쓰기 불가로 남는다 — "OS 락이 풀렸는지 증명할 수 없다"에 대한 fail-closed 응답이고, operationLockClosePoisonsOnlyTheAttestedRootWhenUnlockCannotBeProven이 범위가 해당 root에 한정됨을 확인한다. 두 개의 형제 test(releaseFailureWithSuccessfulChannelCloseReportsStorageWithoutPoisoning, successfulReleaseWithChannelCloseFailureReportsStorageWithoutPoisoning)가 "증명 하나면 충분" 규칙을 양쪽에서 고정한다.

16. Confirmed — 파일시스템 접근이 전부 SecureDirectoryStream 상대 연산이다

SystemSecureRecordOperations의 다섯 연산이 모두 openSecure(topDirectory)newDirectoryStream(shard, NOFOLLOW_LINKS)를 거친다. SecureDirectoryStream이 아니면 스트림을 닫고 IOException을 던진다 — TOCTOU 우회 경로를 열어 두지 않는다.

세부가 촘촘하다.

  • 읽기는 maximumBytes + 1 버퍼로 읽어 한 바이트 초과분을 감지하고, 읽기 전후 fileKeysize를 비교해 "읽는 중 신원이 바뀐" 경우를 integrity 실패로 만든다.
  • 임시 파일 생성은 CREATE_NEW + NOFOLLOW_LINKS + 0600이고, 쓴 뒤 force(true), 그 다음 크기와 fileKey를 생성 시점과 대조한다.
  • 커밋 전후로 requireCreatedTemporaryIdentity정확히 그 fileKey만 지운다 — 다른 프로세스가 같은 이름으로 바꿔 둔 파일을 지우지 않는다. cleanupPreservesAReplacementWhoseNoFollowFileKeyDiffersFromCreatedTemp가 그 경계를 고정한다.
  • shard 디렉터리는 매번 소유자·0700 권한·FileStore 동일성을 재검증하고, 좌표는 [0-9a-f]{2}와 세 허용 디렉터리로 제한된다.
  • 모든 쓰기/읽기 단계 사이에 verifyAttestedIdentity()가 끼어 있다 — root가 도중에 바뀌면 즉시 멈춘다.

forceDirectory는 디렉터리를 READ로 열어 force(true)한다. README가 FILE_AND_DIRECTORY_SYNC를 "attested local file/directory force boundary only"로 한정하는 것과 일치한다.

17. Confirmed — 세 타입 모두 leaf 밖으로 새지 않는다

LocalPersistentControlPlane·FileserverControlRecordCodec·LocalPersistentRecoveryVerifier는 전부 package-private이고, 저장소에서 이 leaf 밖 참조는 0이다(143-... §8.1). production 생성 지점은 FileserverR2Config:51 하나다. CLAUDE.md의 "Leaking filesystem, stream, framework, or provider types across FilePublicationPort" 금지가 타입 가시성으로 뒷받침된다.

R1 하위호환도 좁게 열려 있다 — decodeStoredOperation은 R2 codec을 먼저 시도하고, 실패하면 R1 journal codec으로 넘어가되 terminal PUBLISHED 허용한다. CLAUDE.md의 "schema v1 is strict read-only compatibility"와 일치하고, typedOperationLookupDispatchesCanonicalR2AndTerminalR1FromTheSameHashedPathtypedOperationLookupRejectsMalformedNonCanonicalNonTerminalAndWrongIdentityR1이 양쪽을 고정한다.

18. Negative-space probes — sub-scope 02

  • 8.1 reachability: 세 타입 모두 package-private, leaf 밖 참조 0, production 진입점 1개(§17).
  • 8.2 조건부 형제: 한 클래스 안의 두 락 형태(§14) — 커밋 원시연산 차이로 설명됨. requireOperationLockRootHealthy의 쓰기/읽기 비대칭(§15) — test 이름이 의도임을 명시.
  • 8.3 중복 mechanism: poison 집합에 해제 경로 없음(§15, 의도된 fail-closed). R1/R2 두 codec 경로는 dispatch 순서와 terminal 제약으로 분리(§17).
  • 8.4 문서/동작 대조: README의 상태 사슬(WRITING → SEALED → DATA_PUBLISHED → MANIFEST_PUBLISHED → REFERENCE_PUBLISHED → PUBLISHED)과 isAllowedAdjacentTransition의 행렬이 일치. FILE_AND_DIRECTORY_SYNC의 한정 서술과 forceDirectory 구현이 일치.

19. Sub-scope 02 findings backlog

우선순위 finding reachability
없음. 후보로 본 세 가지(immutable 락의 root 미포함, poison 해제 경로 부재, 읽기 경로의 health 게이트 부재)는 각각 커밋 원시연산·fail-closed 설계·명시적 test로 의도임이 확인됐다

20. Sub-scope 02 완료 조건

  • denominator 6 / 6 FULL_READ (143-... OWNED FILES) — main 2,535 LOC 전수 판독
  • §8.1~§8.4 네 종 probe 수행, 후보 finding 3건을 각각 코드·test로 추적해 결함 아님으로 판정
  • 실행 probe 불필요 — 세 후보 모두 소스와 test 이름으로 정적 결정 가능
  • 소스 미변경

21. Sub-scope 03 범위와 denominator

내부 상태: COMPLETE — 26 / 26 FULL_READ 범위: publication main 19 (provider·adapter·journal·attestor·binding·record, 약 4,400 LOC) + 전용 test 7 역할: 요청 → 스테이지 → 데이터 → manifest → reference → terminal 사슬을 재개 가능한 상태 기계로 만들고, 루트를 startup에 증명하며, R1 아티팩트를 읽기 전용으로만 복원한다

manifest와 probe: evidence/raw/144-fileserver-publication-probes.txt.

22. Confirmed — 19개 production 타입 중 leaf를 벗어나는 것이 하나도 없다

전수 검색 결과 LocalPersistentPublicationProvider·LocalFilePublicationAdapter·RoutingFilePublicationAdapter·LocalPersistentRootAttestor·FileserverBindingCompiler·DurablePublicationRecord·PrivateFileManifest·PublishedReferenceRecord·LocalPublicationJournal 어느 것도 이 leaf 밖에서 참조되지 않는다(144-... §8.1, exit=1). 전부 package-private이고, 애플리케이션이 보는 것은 FilePublicationPort와 그 값 타입뿐이다.

CLAUDE.md의 금지 조항 — "Leaking filesystem, stream, framework, or provider types across FilePublicationPort" — 이 문서가 아니라 타입 가시성으로 뒷받침된다. FilePublicationProvider(adapter 내부 provider 인터페이스)도 package-private이라 provider 개념 자체가 포트를 건너지 않는다.

23. Confirmed — 복구가 "어디서 끊겼든 그 자리에서" 재개하는 루프다

recoverR2는 저장된 상태에 따라 분기하는 while(true) 루프다. 각 단계가 증거를 다시 검증하고, 성공하면 다음 상태로 전이하며, 루프가 PUBLISHED에 도달하면 receipt를 복원한다.

저장 상태 재개 동작
WRITING 격리(UNSEALED_WRITING) — 봉인 전에 끊긴 것은 재개하지 않는다
SEALED stage/data를 조사해 둘 다 없으면 integrity 실패, data가 있으면 디렉터리만 force, 없으면 stage를 hard-link로 publish
DATA_PUBLISHED manifest를 찾거나 생성해 저장
MANIFEST_PUBLISHED reference를 찾거나 생성해 저장
REFERENCE_PUBLISHED 모든 증거를 재대조하고 receipt snapshot을 넣어 terminal 기록
PUBLISHED 전 필드 재검증 후 저장된 receipt를 그대로 반환
QUARANTINED indeterminate

핵심은 producer를 다시 부르지 않는다는 점이다. publishNewstreamRequest를 호출하고, 그 이후의 모든 재개 경로는 이미 봉인된 바이트에서 진행한다. README의 "resumes from verified sealed bytes without replaying the producer"가 코드 구조로 성립한다.

resumeData의 stage/data 이중 조사가 특히 촘촘하다. 둘 다 존재하면 fileKey가 같은지 확인해 — 즉 같은 exclusive hard-link인지 — 확인하고, 다르면 RecoveryIntegrityException이다. hard-link 발행이 성공한 뒤 stage 삭제 전에 죽은 경우와, 전혀 다른 파일이 그 자리에 있는 경우를 구분한다.

실패 분류도 갈라져 있다. RecoveryIntegrityException과 payload의 INTEGRITY/CAPACITY격리 후 indeterminate가 되고, 그 밖의 payload 실패는 격리 없이 indeterminate다. quarantineAndIndeterminate는 이미 PUBLISHED/QUARANTINED인 기록은 건드리지 않는다.

24. Confirmed — 루트 증명이 "설정을 믿지 않는" 형태다

LocalPersistentRootAttestor.attestChecked가 순서대로 확인한다: 절대·정규화 경로 → 심볼릭 루트/조상 거부 → toRealPath()가 설정 경로와 정확히 일치 → 소유자 → 권한 상한 → FileStore 이름·타입 → mount sentinel의 SHA-256 → 내부 디렉터리 8개 생성/검증 → SecureDirectoryStream 가용성 → 실제 capability probe.

마지막이 특징적이다. runCapabilityProbe는 실제로 파일을 만들고(CREATE_NEW+NOFOLLOW_LINKS+0600), 쓰고, force하고, hard-link를 만들고, 디렉터리를 force한 다음, 원본과 링크의 fileKey가 같은지 확인한다. 즉 "이 파일시스템이 배타적 hard-link 발행과 file/directory force를 실제로 할 수 있는가"를 startup에 시험한다 — 첫 publication에서 발견하지 않는다.

내부 디렉터리 생성에는 롤백이 붙어 있다. rollbackCreatedDirectory는 삭제 전에 부모 identity와 디렉터리 자신의 fileKey를 대조하고, 하나라도 바뀌었으면 삭제를 거부한다("refusing rollback because internal directory identity changed"). 실패 정리가 남의 디렉터리를 지우지 않는다.

verifyIdentity는 attest가 끝난 뒤에도 control plane의 거의 모든 단계에서 재호출된다(§16). 증명은 시점이 아니라 불변식이다.

25. Confirmed — canonical digest가 길이 프레이밍이고, route token 충돌을 명시적으로 검사한다

FilePublicationCanonicalDigests.digestOrderedValues는 값 개수를 먼저 넣고, 값마다 길이(4바이트) + 엄격 UTF-8 바이트를 넣는다. 구분자를 쓰지 않으므로 값 안에 어떤 문자가 있어도 경계가 흐려지지 않는다. FilePublishRequestFingerprint도 같은 방식이다.

routeToken은 정책 다이제스트의 앞 31자에 r을 붙인 것이라 잘린 값이다. 그래서 FileserverBindingCompiler.deriveUniqueRouteTokens가 컴파일 시점에 토큰 충돌을 검사하고, 충돌하면 두 destination 이름을 모두 담아 거부한다. 잘림이 만들 수 있는 유일한 문제를 그 자리에서 닫는다. 컴파일 후에도 compiled.forEach로 각 destination의 토큰이 레지스트리와 같은지 다시 확인한다.

CompiledFileDestination의 compact 생성자는 넘겨받은 effectivePolicyDigest다시 계산해 대조하고, routeToken이 그 다이제스트에서 유도됐는지, formatPolicyDigest가 정본과 같은지도 확인한다. 값이 아니라 관계를 검증한다.

26. Confirmed — R1과 R2가 같은 일을 다른 엄격도로 하고, 그 사실이 선언돼 있다

두 계층이 나란히 있어 비교가 가능하다(144-... §8.2).

R2 LocalPersistentControlPlane R1 LocalPublicationJournal
파일시스템 접근 SecureDirectoryStream 상대 연산 (17회) Files.exists/isRegularFile/readAllBytes (0회)
읽기 디코딩 엄격 UTF-8 REPORT + canonical 바이트 재대조 new String(bytes, UTF_8) — malformed는 U+FFFD로 대체
제어문자 이스케이프 \b \f \n \r \t문법에서 거부 다섯 개를 모두 수용해 디코드
POSIX 권한 정확히 0700이 아니면 실패 UnsupportedOperationException을 삼키고 진행
임시 파일명 SecureRandom 16바이트 hex UUID.randomUUID()
ReentrantLock 스트라이프 + OS FileLock + poison 래치 Semaphore 스트라이프 + OS FileLock

이것은 결함이 아니라 선언된 상태다 — CLAUDE.md는 R1을 "compatibility only"로, README는 "must not be used as R2 durability or cluster-safety evidence"로 못박는다.

중요한 것은 두 계층이 만나는 한 지점이다. R2 control plane이 같은 해시 경로에서 R1 저널을 읽을 때(decodeStoredOperation) 쓰는 것은 관대한 decode가 아니라 **엄격한 decodeCanonical**이고, 그 위에 state == PUBLISHED까지 요구한다(LocalPersistentControlPlane:634-638). 즉 R1의 느슨함이 R2 경로로 흘러들지 않는다. 이 한 줄이 위 표 전체를 안전하게 만든다.

R1 복원이 등급을 올리지 않는 것도 코드로 확인된다 — restoreR1은 receipt에 DurabilityGuarantee.PROCESS_LOCAL_SYNC를 그대로 넣고, 참조도 R2의 fsr1.… 형식이 아니라 R1의 filepub:<destination>:<token> 형식을 쓴다. README의 "never writes schema v1, creates an R2 manifest/reference for that artifact, or promotes its durability guarantee"와 일치한다.

27. Negative-space probes — sub-scope 03

  • 8.1 reachability: 19개 production 타입 전부 package-private, leaf 밖 참조 0(§22). production 진입점은 FileserverR2Config(R2)와 FileExportConfig(R1) 둘.
  • 8.2 조건부 형제: R1/R2의 6개 축 엄격도 대조(§26), 그리고 두 계층의 접점이 엄격 경로를 쓰는지 확인.
  • 8.3 중복 mechanism: 참조 형식 둘(filepub: / fsr1.)과 락 구현 둘 — 각각 R1/R2 경계에 대응하고 서로 침범하지 않음. LocalPersistentPublicationProviderLocalFilePublicationAdapter가 같은 filepub: 형식을 쓰는 것은 R1 receipt 호환을 위한 의도된 공유.
  • 8.4 문서/동작 대조: README의 여섯 단계 사슬 ↔ recoverR2 분기, "producer를 재생하지 않는다" ↔ publishNewstreamRequest 호출, R1 등급 비승격 ↔ PROCESS_LOCAL_SYNC 고정, route token 잘림 ↔ 컴파일 시 충돌 검사.

28. Sub-scope 03 findings backlog

우선순위 finding reachability
없음. R1/R2 엄격도 격차는 문서가 선언한 상태이고, 두 계층이 만나는 유일한 지점(decodeStoredOperation)은 엄격 경로를 쓴다

29. Sub-scope 03 완료 조건

  • denominator 26 / 26 FULL_READ (144-... OWNED FILES) — main 약 4,400 LOC 전수 판독
  • §8.1~§8.4 네 종 probe 수행, R1/R2 접점을 코드로 추적해 느슨함이 전파되지 않음을 확인
  • 실행 probe 불필요 — 판정 지점이 모두 정적으로 결정 가능
  • 소스 미변경

30. Sub-scope 04 범위와 denominator

내부 상태: COMPLETE — 30 / 30 FULL_READ 범위: platform/local IO 원시연산 9 + gateway 7 + store·publisher 6 (main 22) + 전용 test 8 역할: HTTP Fileserver 플랫폼의 로컬 콘텐츠 저장소 — 스테이징·추가·발행·읽기·삭제를 경로가 아니라 디렉터리 서술자 상대 연산으로 수행한다

manifest와 probe: evidence/raw/145-fileserver-local-io-probes.txt.

31. Confirmed — TOCTOU를 "검사를 더 하는" 방식으로 풀지 않는다

SecureDirectoryWalk의 클래스 javadoc이 이 sub-scope의 설계 명제를 그대로 적는다.

The pathname approach cannot be made safe by adding checks. Proving that no component of ${root}/content/ab/cd is a symbolic link and then calling FileChannel.open on that string re-resolves every component from scratch… More checks only narrow the window; they never close it.

그래서 각 단계가 이전 디렉터리의 서술자를 기준으로 다음 디렉터리를 NOFOLLOW_LINKS로 연다. 열어 둔 서술자는 나중에 그 디렉터리가 교체돼도 영향받지 않는다 — "an attacker who swaps a component afterwards has swapped something nothing is looking at any more".

세부도 논리적이다.

  • fallback을 두지 않는다. SecureDirectoryStream이 없으면 startup capability probe가 실패로 처리한다 — "a silent fall back to pathnames would restore exactly the window this class exists to close".
  • 거부와 장애를 구분한다. NOFOLLOW_LINKS 거부는 플랫폼이 일반 FileSystemException으로 보고하므로, 실패 시 같은 부모 서술자로 그 컴포넌트를 다시 읽어 심볼릭 링크인지 확인하고 SymbolicComponentException(영구 거부)과 스토리지 장애(재시도 가능한 503)를 나눈다.
  • FileChannel이 아니면 거부한다. requireFileChannel은 positional write·truncate·force·transferTo가 전부 FileChannel 연산이고 "cannot be emulated"라고 적으며 거부한다.
  • 경로 해석은 한 곳뿐. DefaultPhysicalPathResolver가 유일하게 식별자를 경로로 바꾸고, 세 겹으로 막는다 — 서버 생성 형태 정규식([a-z0-9]{2}/[a-z0-9]{2}/[a-z0-9_-]{12,190}), 정규화, 영역 루트 startsWith 재확인. 클라이언트 파일명은 어느 단계에도 들어오지 않는다(resolutionNeverDependsOnAClientFilename가 고정).

LocalAppendEngine의 롤백 설계도 촘촘하다. 실패하면 누산 다이제스트를 먼저 버리고(이미 버려질 바이트를 흡수했으므로), truncateforcesize 재확인으로 물리 길이가 append 이전으로 돌아왔음을 증명한 뒤에야 원래 실패를 그대로 던진다. 증명하지 못하면 AmbiguousCompletionException으로 격상해 reconciliation에 넘긴다. 선언된 content length는 사후 검사가 아니라 읽기 상한으로 쓰이고(buffer.limit(min(capacity, contentLength - appended))), 잉여는 1바이트 probe read로 감지해 버린다.

LocalBlockingContentStore는 20곳 전부 channels.*(서술자 상대)를 쓰고 Files.*를 한 번도 부르지 않는다(145-... §8.2).

32. P3 — 발행 rename만 경로 기반이고, 그것을 지키는 것은 이 모듈이 "근사에 불과하다"고 적은 사전검사다

platform/local에 남은 java.nio.file.Files.* 호출을 전수 조사했다(145-... §8.2). 대부분은 정당하다 — SecureDirectoryWalk.openRoot(문서가 "the one unavoidable pathname resolution"이라 적는 루트 열기), LocalStorageCapabilityProbe(startup probe, 격리된 probe 영역), LocalOrphanScanAdapter·LocalStorageHealthAdapter·LocalStorageUsageProbe(sub-scope 05).

문제는 쓰기 경로에 남은 다섯 호출이다.

AtomicMoveContentPublisher:53   Files.move(staging, target, ATOMIC_MOVE)      ← 발행 rename
AtomicMoveContentPublisher:113  Files.exists(staging, NOFOLLOW_LINKS)         ← 실패 분류
AtomicMoveContentPublisher:114  Files.exists(target, NOFOLLOW_LINKS)          ← 실패 분류
ContentPublishVerification:53   Files.size(target)                            ← 발행 크기
MetadataPointerContentPublisher:107  Files.deleteIfExists(staging)

그리고 AtomicMoveContentPublisher:50이 그 rename 직전에 부르는 것은 channels.requireNoSymlinkBetween(root, target.getParent()) — 즉 경로 기반 사전검사다. 그 메서드의 javadoc이 스스로를 이렇게 설명한다.

Retained for the capability probe, which still reasons about pathnames. Production access no longer relies on it: descending descriptor by descriptor with NOFOLLOW_LINKS refuses a symlinked component by construction, which a precheck could only ever approximate.

즉 "production은 더 이상 이것에 의존하지 않는다"고 적힌 메서드를, 콘텐츠를 보이게 만드는 바로 그 단계가 유일한 보호로 쓴다. SecureDirectoryWalk의 "More checks only narrow the window; they never close it"이 겨냥한 패턴 그 자체다.

같은 불일치가 파일 길이에서도 보인다. LocalAppendEngine.currentLength는 여덟 줄짜리 javadoc으로 왜 Files.size가 틀렸는지 설명하고 channels.readAttributes(root, staging)를 쓴다 — "an attacker who swaps the parent for a symlink gets this check to report the size of their own file". ContentPublishVerification.sizeOf는 같은 질문에 Files.size(target)으로 답한다.

판정: P3. 실제 악용에는 스토리지 루트 안쪽 쓰기 권한이 필요하고, 이 leaf가 그 루트의 소유자·권한을 증명하는 것은 R2 경로(LocalPersistentRootAttestor)뿐이며 플랫폼 저장소 루트의 증명은 app-bootstrap의 startup validator 몫이다. 그래서 도달성은 배포 형상에 달려 있다. 심각도를 P3로 두는 이유는 그것이고, 그럼에도 기록하는 이유는 모듈 자신의 문서가 이 패턴을 명시적으로 불충분하다고 선언했다는 점이다. 수정은 발행 rename을 SecureDirectoryWalk.inParentOf로 옮겨 부모 서술자 상대 move를 쓰고, sizeOfchannels.readAttributes로 바꾸는 것이다.

33. Confirmed — 두 발행 전략이 probe 결과로 선택되고, 각자 다른 실패를 다르게 분류한다

selectPublisher는 설정이 아니라 probe가 증명한 것에서 전략을 고른다. ATOMIC_MOVE_REQUIRED는 원자적 이동을 증명하지 못하면 fail-closed, ATOMIC_MOVE_PREFERRED는 pointer 발행으로 강등된다. ContentPublisherTest가 양쪽을 고정한다(requiredAtomicModeFailsClosedWhenTheProbeCouldNotProveIt, preferredModeDegradesToPointerPublishWhenAtomicMoveIsUnproven).

두 전략 모두 REPLACE_EXISTING을 쓰지 않는다 — 기존 대상은 조용한 덮어쓰기가 아니라 충돌이다. 그리고 결과를 증명할 수 없으면 성공도 실패도 아닌 AmbiguousCompletionException이다.

AtomicMoveContentPublisher.forceDirectoryEntries의 근거가 특히 정확하다 — 스테이징 파일을 force하는 것은 내용을 지속시킬 뿐 그것을 가리키는 디렉터리 엔트리에 대해서는 아무 말도 하지 않는다. 크래시 후 객체가 완전히 쓰였으면서 동시에 두 디렉터리 어디에도 없고 메타데이터는 READY라고 말하는 상태가 가능하다. rename은 두 디렉터리를 바꾸므로 둘 다 sync하고, sync 실패는 무시가 아니라 ambiguous로 격상한다.

MetadataPointerContentPublisher는 복사 후 디스크에서 다시 다이제스트를 계산해 스테이지 다이제스트와 비교한다. ContentPublishVerification의 javadoc이 그 원칙을 적는다 — "recomputed from the bytes actually on disk rather than trusted from the streaming accumulator, so a publish can never advertise a hash the stored object does not have".

34. P3 — TransferBufferPool.maxBorrowedBytes()가 자기 회귀 test를 지목하는데 그 test가 읽지 않는다

TransferBufferPool은 대여 중 바이트의 최대치를 추적하고 javadoc에 이렇게 적는다.

Peak simultaneously-borrowed bytes; the bounded-memory regression asserts on this.

145-... §8.4의 전수 검색에서 maxBorrowedBytesTransferBufferPool.java 세 줄에만 나타나고, LocalAppendMemoryTest에도 LargeFileBoundedMemoryTest에도 없다. 즉 회귀 test는 이 값을 읽지 않는다.

기능 자체는 옳게 동작한다 — borrowbufferSize만큼 증가시키고 release가 되돌리며, 최대치를 accumulateAndGet(_, Math::max)로 누적한다. 그리고 경계 자체(전송이 파일 크기에 비례해 메모리를 쓰지 않음)는 다른 방식으로 검증되고 있다. 문제는 javadoc이 존재하지 않는 결합을 서술한다는 것이고, 그 서술 때문에 이 계측이 지켜지고 있다고 읽힌다. P3.

35. Negative-space probes — sub-scope 04

  • 8.1 reachability: platform/local의 15개 타입이 public이고, leaf 밖에서는 app-bootstrap의 fileserver autoconfigure 5개 클래스가 참조한다. 나머지(SecureDirectoryWalk·SafeFileChannelFactory·DefaultPhysicalPathResolver·publisher 3종·LocalUploadHandle 등)는 package-private — Path가 SPI를 건너지 않는다는 주장이 가시성으로 성립.
  • 8.2 조건부 형제: 파일 길이를 묻는 두 방식(§32), 서술자 상대 vs 경로 기반 쓰기(§32).
  • 8.3 중복 mechanism: 두 발행 전략은 중복이 아니라 probe 결과로 배타 선택되고 usesAtomicMove()로 자기 성격을 보고한다(§33).
  • 8.4 문서/동작 대조: maxBorrowedBytes javadoc의 회귀 test 결합 부재(§34). LocalCopyContentGateway의 "A copy is not a link" 근거와 실제 스테이징 경유 복사 구현 일치. LocalZeroCopyDownloadGateway의 짧은 전송 재시도와 부분 전송 보고 일치.

36. Sub-scope 04 findings backlog

우선순위 finding reachability
P3 발행 rename(Files.move)과 그 실패 분류(Files.exists), 발행 크기(Files.size)가 경로 기반이고, 유일한 보호는 이 모듈이 "a precheck could only ever approximate"라고 적은 requireNoSymlinkBetween이다 스토리지 루트 안쪽에 쓰기 권한을 가진 주체 — 루트 증명은 배포 형상에 달려 있다
P3 TransferBufferPool.maxBorrowedBytes()의 javadoc이 "the bounded-memory regression asserts on this"라고 적지만 어떤 test도 읽지 않는다 계측/문서

37. Sub-scope 04 완료 조건

  • denominator 30 / 30 FULL_READ (145-... OWNED FILES)
  • §8.1~§8.4 네 종 probe 수행, platform/localFiles.* 호출을 전수 조사해 정당한 것과 남은 것을 분리
  • 두 finding 모두 정적으로 결정 가능(호출 지점과 javadoc 대조)하여 실행 probe 불필요
  • 소스 미변경

38. Sub-scope 05 범위와 denominator

내부 상태: COMPLETE — 29 / 29 FULL_READ 범위: platform/local 실패분류·probe·health·orphan 10 + platform/verification 10 + platform/security 2 + platform/audit 2 (main 24) + 전용 test 5 역할: 콘텐츠 검증 사슬, 역할 기반 인가, 감사 기록, 그리고 파일시스템 실패를 "일어났는가"로 분류하는 계층

manifest·정적 probe·실행 probe: evidence/raw/146-fileserver-verification-security-audit-probes.txt.

39. P2 확정 — §4의 README 주장이 여덟 개의 port 구현과 여덟 개의 bean 앞에서 성립하지 않는다

sub-scope 01(§4)에서 이월한 판정을 여기서 닫는다. README:103105는 audit·readiness/health·reconciliation/reaping·quota가 "not implemented"이고 "No setting or bean for those capabilities is exposed"라고 적는다. 실제로는 이 sub-scope의 타입들이 application-core port를 구현하고, app-bootstrap이 그 전부를 bean으로 만든다(146-... §8.1).

port 구현 bean 생성
AdminAuditPort StructuredAdminAuditAdapter FileserverSecurityConfiguration:71
FileserverAuditPort StructuredFileserverAuditAdapter :77
FileAccessPolicy RoleBasedFileAccessPolicy / UnenforcedFileAccessPolicy :51 / :90
StorageHealthPort LocalStorageHealthAdapter FileserverStorageConfiguration:179
OrphanScanPort LocalOrphanScanAdapter :189
StorageUsageProbe LocalStorageUsageProbe :196
ReconciliationContentProbe LocalReconciliationContentProbe :203

스텁이 아니다. 감사 어댑터는 전용 로거 카테고리(dev.caskeleton.fileserver.audit)로 쓰고, 실패한 동작을 성공과 같은 레벨로 남긴다("a refused force-delete is the entry a reviewer most needs to find"). health 어댑터는 원자적 이동 가능 여부를 설정이 아니라 probe가 증명한 사실에서 보고한다. usage probe는 매 호출마다 FileStore를 다시 읽고, 읽을 수 없으면 0%도 100%도 아닌 빈 답을 낸다("a synthetic 0% would silently disable the high-water guard, and a synthetic 100% would take the capability down over a failed syscall").

즉 코드 쪽은 잘 만들어져 있고, 틀린 것은 README 한 문단이다. §4에서 적은 대로 이것들은 R2 publication이 아니라 HTTP Fileserver 플랫폼의 부품이지만, 그 문단에는 한정어가 없다. P2 확정.

40. P2 — scriptable 콘텐츠 탐지가 접두사 시작에만 고정돼 있어 BOM·NUL·주석으로 우회된다

ScriptableContentPolicy의 javadoc은 이 검사의 목적을 분명히 적는다.

Guards content that a browser would execute if it were ever served inline. Detection is on content, not on the claimed type or the extension, because both are attacker controlled.

구현은 1,024바이트 접두사를 소문자로 만든 뒤 stripLeading()하고, 여섯 마커(<!doctype html, <html, <script, <svg, <?xml, <!entity) 중 하나로 시작하는지만 본다.

hermetic 실행 probe로 실제 판정을 측정했다(146-... EXECUTION PROBE, inlineSafeProfile=false 강제 프로파일).

PROBE plain <script>                 -> QUARANTINE / SCRIPTABLE_CONTENT
PROBE plain <html>                   -> QUARANTINE / SCRIPTABLE_CONTENT
PROBE leading whitespace + <html>    -> QUARANTINE / SCRIPTABLE_CONTENT
PROBE uppercase <SCRIPT>             -> QUARANTINE / SCRIPTABLE_CONTENT
PROBE <svg onload>                   -> QUARANTINE / SCRIPTABLE_CONTENT
PROBE UTF-8 BOM + <html>             -> ACCEPT / NO_SCRIPTABLE_CONTENT     ←
PROBE HTML comment then <script>     -> ACCEPT / NO_SCRIPTABLE_CONTENT     ←
PROBE NUL byte then <html>           -> ACCEPT / NO_SCRIPTABLE_CONTENT     ←
PROBE plain text                     -> ACCEPT / NO_SCRIPTABLE_CONTENT

세 가지가 통과한다. String.stripLeading()Character.isWhitespace만 제거하므로 UTF-8 BOM(U+FEFF)도 NUL도 지우지 않고, 선행 HTML 주석은 어떤 마커로도 시작하지 않는다. 셋 다 브라우저는 HTML로 렌더링한다 — BOM 접두 HTML은 이국적인 우회가 아니라 여러 편집기의 기본 출력이다.

형제 검증기와의 대비가 판정을 굳힌다. MediaTypeVerifier는 매직바이트를 접두사 시작에서 비교하는데, 그것은 시그니처의 정의가 파일 선두이므로 옳다. scriptable 마커는 시그니처가 아니라 브라우저가 스니핑하는 패턴이고, 브라우저는 선두 고정 매칭을 하지 않는다. 같은 "접두사 시작 비교"가 한쪽에서는 정확하고 다른 쪽에서는 우회 가능하다.

판정: P2. inlineSafeProfile=false인 배포에서 도달 가능하고, 그 프로파일이 바로 격리를 강제하려는 설정이다. 수정은 startsWith를 접두사 탐색으로 바꾸고, 매칭 전에 BOM·NUL·제어바이트를 제거하는 것이다. (완화 요인: MediaTypeVerifier가 claimed 타입과 감지 타입의 불일치를 별도로 격리하므로, Content-Type: text/html을 선언한 업로드는 다른 경로로 걸린다. 타입을 선언하지 않거나 application/octet-stream을 선언하면 걸리지 않는다.)

41. Confirmed — 검증 사슬의 합성이 fail-closed다

VerificationCoordinator는 검증기마다 시한을 두고, timeout·interrupt·예외를 전부 RETRY 만든다 — ACCEPT가 아니다. javadoc이 이유를 적는다: "an unavailable scanner can never publish content by failing open."

VerificationPolicyCombiner의 우선순위는 REJECT > QUARANTINE > RETRY > ACCEPT이고, RETRYACCEPT보다 높다는 것이 핵심이다 — 답하지 못한 검증기가 답한 검증기들에게 조용히 덮이지 않는다. 결과가 비면 NO_VERIFIER_ANSWEREDRETRY다. REJECT에 도달하면 뒤 검증기를 건너뛴다("A reject cannot be overturned").

개별 검증기도 방향이 옳다. LengthVerifier가 먼저 돌아 정책이 이미 배제한 콘텐츠에 뒤 검증기가 일하지 않게 하고, MediaTypeVerifier는 시그니처 일치를 안전 판정으로 쓰지 않고 기록할 타입만 정하며 claimed와 detected의 불일치를 격리한다. FilenamePolicyVerifier는 저장된 이름을 다시 sanitize해서 달라지면 거부가 아니라 격리한다 — 미정제 텍스트가 메타데이터 저장소에 들어갔다는 뜻이므로 콘텐츠 문제가 아니라 결함이기 때문이다.

LocalVerificationContentReader는 스테이징(업로드 중)과 발행 콘텐츠(재검증) 양쪽을 읽는다 — "otherwise re-verifying a quarantined file would silently inspect nothing and accept it".

42. Confirmed — 인가와 감사가 정보를 흘리지 않는다

RoleBasedFileAccessPolicy는 열 개 연산을 READ/WRITE/ADMIN 세 계층으로 접는다. 근거가 적혀 있다 — 연산별 역할 맵은 COPY를 주고 CREATE를 안 주는 조합을 허용하는데 "a copy creates a file"이므로 제한처럼 보이고 제한이 아니다. admin은 write를 상속하지 않는다 — 삭제할 수 있다는 이유로 force-delete까지 되면 감사되는 관리 평면이 일반 데이터 평면으로 도달 가능해진다. 빈 admin 역할 집합은 생성자가 거부한다("would leave the management plane unreachable rather than protected").

거부 메시지는 필요한 역할도 주체의 역할도 말하지 않는다 — "a denial that reported what was missing would turn every 403 into a readable description of the role model".

UnenforcedFileAccessPolicy의 설계도 기록할 만하다. 이름 자체가 장치다 — composition root가 타입 이름으로 매치해 production startup을 거부한다. "A permissive default that looked like a real policy would ship as one."

실패 메시지 위생도 일관된다. LocalStorageFailures의 어떤 메시지도 경로·마운트·루트를 담지 않고, 감사 어댑터가 쓰는 필드는 전부 지문·코드·불투명 식별자다.

43. Confirmed — 실패를 "재시도 안전한가"로 분류한다

AmbiguousFilesystemOperationDetectorIOException을 네 결과로 나눈다(NOT_SENT / DEFINITELY_REJECTED / AMBIGUOUS_COMPLETION / RECONCILIATION_REQUIRED). 기본값이 보수적이다 — 인식하지 못한 실패는 변경 연산이면 ambiguous다. javadoc이 비대칭을 적는다: "the cost of a wrong 'safe to retry' is a corrupted object, while the cost of a wrong 'ambiguous' is one reconciliation entry."

mutating 인자로 순수 읽기는 결코 ambiguous가 되지 않게 하고, stale handle은 변경 연산일 때 RECONCILIATION_REQUIRED로 격상한다 — 에러만으로는 결과를 알 수 없으므로 물리 증거를 다시 읽어야 한다.

다만 isStaleHandle·isLostResponseFilesystemFailureClassifier.isOutOfSpace메시지 텍스트 매칭에 의존한다("stale file handle", "estale", "timed out", "No space left on device", "Disk quota exceeded"). 후자에는 주석이 붙어 있다 — "The JDK has no dedicated exception for this, so the reason text is the only available signal." 로케일이나 JDK 판본에 따라 문구가 달라지면 분류가 기본값으로 떨어지는데, 기본값이 보수적(변경 연산 → ambiguous)이므로 안전한 방향이다. 기록만 한다.

44. Negative-space probes — sub-scope 05

  • 8.1 reachability: 8개 port 구현과 app-bootstrap의 8개 bean 생성 지점을 직접 확인해 §4를 확정(§39).
  • 8.2 계약 ↔ 구현: scriptable 탐지의 선언("detection is on content")과 실제 매칭 범위(접두사 시작 고정)의 격차 — 실행 probe로 확정(§40).
  • 8.2b 조건부 형제: 같은 "접두사 시작 비교"가 MediaTypeVerifier에서는 정확하고 ScriptableContentPolicy에서는 우회 가능(§40).
  • 8.3 fail-closed 합성: coordinator의 timeout/예외 → RETRY, combiner의 RETRY > ACCEPT 우선순위(§41).
  • 8.4 위생/문서: 실패·감사 메시지에 경로 없음, 거부 메시지에 역할 없음(§42). 메시지 텍스트 매칭 의존과 그 보수적 기본값(§43).

45. Sub-scope 05 findings backlog

우선순위 finding reachability
P2 README:105 "No setting or bean for those capabilities is exposed"가 audit·health·reaping·quota 네 능력에 대해 사실과 다르다 — 8개 port 구현과 app-bootstrap의 8개 bean으로 확정 이 문단으로 능력 유무를 판단하는 독자
P2 ScriptableContentPolicy가 마커를 접두사 시작에서만 찾아, UTF-8 BOM·NUL·선행 HTML 주석이 붙은 실행 가능 콘텐츠를 ACCEPT한다 (실행 probe 3건) inlineSafeProfile=false이고 claimed 타입을 선언하지 않는 업로드
P3/기록 실패 분류가 예외 메시지 텍스트("stale file handle", "timed out", "No space left on device")에 의존한다 — 문구가 달라지면 보수적 기본값으로 떨어지므로 안전한 방향 로케일/JDK 판본이 다른 배포

46. Sub-scope 05 완료 조건

  • denominator 29 / 29 FULL_READ (146-... OWNED FILES)
  • §8.1~§8.4 네 종 probe 수행, sub-scope 01에서 이월한 P2를 port 구현·bean 생성 지점으로 확정
  • scriptable 탐지 우회를 hermetic 실행 probe 3건으로 확정
  • 임시 probe class 1개 추가 후 제거, git status --short = 0, 소스 미변경

47. Sub-scope 06 범위와 denominator

내부 상태: COMPLETE — 15 / 15 FULL_READ 범위: LocalPersistentPayloadOperations 1,101 + StreamingCsvEncoder 190 + FilesystemCsvExportAdapter 188 (main 3) + 전용 test 3 + testkit 9 역할: R2 payload의 보안 경계, RFC-4180 스트리밍 인코딩(수식 완화 포함), 그리고 store 계약·크래시 행렬·NFS 모호성 testkit

manifest와 probe: evidence/raw/147-fileserver-payload-testkit-probes.txt.

48. Confirmed — payload 계층이 자신의 잔여 위험을 먼저 선언한다

LocalPersistentPayloadOperations의 클래스 javadoc이 무엇이 서술자 상대이고 무엇이 아닌지를 앞에서 밝힌다.

Reads, writes, and exact deletes use SecureDirectoryStream; the JDK's missing relative hard-link, directory-create, and directory-force primitives are bracketed by attested identity checks in this class.

전수 검사가 그 서술과 일치한다(147-... §8.2). 이 파일의 Files.* 호출은 정확히 그 셋 — Files.createLink(:941), Files.createDirectory(:802), 그리고 force/stat/FileStore 조회 — 뿐이고, 각각 앞뒤로 fileKey·소유자·권한·FileStore 재확인이 붙는다. JDK가 linkat/mkdirat를 노출하지 않으므로 서술자 상대 대응물이 없고, 그 사실을 숨기는 대신 적었다.

이것이 §32와의 차이다. 여기서는 잔여 경로 연산이 (a) 문서에 선언되고 (b) identity 검사로 감싸인다. AtomicMoveContentPublisher의 발행 rename은 (a) 어디에도 선언되지 않고 (b) 같은 모듈이 "a precheck could only ever approximate"라고 적은 사전검사 하나로만 보호된다. 같은 저장소가 같은 문제를 한 번은 정직하게, 한 번은 그렇지 않게 다룬 대비다.

49. Confirmed — CSV 인코더가 스트리밍이고 세 가지 상한을 동시에 건다

StreamingCsvEncoder는 행 단위로 쓰고 즉시 다이제스트에 넣는다. 상한이 셋이다 — 행 수(maximumRows), 누적 바이트(maximumBytes, bytesWritten > maximumBytes - bytes.length로 오버플로 없이 검사), 그리고 컬럼별 UTF-8 바이트(column.maximumUtf8Bytes()). 셀 타입이 스키마와 다르면 거부하고, null은 컬럼이 nullable일 때만 빈 문자열이 된다.

수식 주입 완화는 세 정책으로 갈린다 — ALLOW/MITIGATE(' 접두, 카운트 증가)/REJECT. 후보 판정은 첫 문자가 =, +, -, @, \t, \r인지다(OWASP 권고 집합). formulaMitigatedCount가 receipt와 control record까지 전달되므로(§13의 requireFormulaCountWithinCells) 완화가 일어났다는 사실이 감사 가능한 값으로 남는다.

checkpoint()가 매 행 앞뒤로 스레드 인터럽트를 확인해 협력적 취소를 지원한다.

R1 legacy 어댑터도 두 결함을 이미 고쳤다고 주석에 남긴다 — 전체를 StringBuilder에 모으던 방식(백만 행이면 OOM)을 bounded writer 스트리밍으로, Files.write의 조용한 truncate를 CREATE_NEW로. 다만 R1이 "a stand-in for NFS/SFTP"라고 자칭하는 것은 CLAUDE.md의 "Advertising shared-mounted/NFS, SFTP … as implemented" 금지와 나란히 두면 표현이 조심스럽다("stand-in"은 구현 주장이 아니다). 결함으로 올리지 않는다.

50. Confirmed — testkit이 크래시 지점을 열거해 전수 검증한다

CrashRecoveryMatrixTest@EnumSource(CrashPoint.class)모든 크래시 지점에 대해 두 불변식을 건다.

  • aPublishedObjectIsAlwaysCompleteAndDigestMatched
  • aCrashNeverLeavesAPartialObjectUnderThePublishedKey

즉 "어떤 지점에서 죽어도 발행된 키 아래에 부분 객체가 없다"를 지점별로 확인한다. AFTER_CREATE·DURING_APPEND·AFTER_APPEND_COMMIT·BEFORE_PUBLISH·AFTER_METADATA_BEFORE_QUOTA 등이 열거돼 있어, 새 지점을 추가하면 두 test가 자동으로 그것을 포함한다.

ContentStoreContract는 추상 계약이고 LocalContentStoreContractTest(원자적 이동)와 MetadataPointerContentStoreContractTest(포인터 발행)가 각각 상속한다 — 두 발행 전략이 같은 계약을 통과해야 한다는 것을 구조로 강제한다. 계약 항목도 성질 중심이다: 왕복, 다중 append의 다이제스트가 모든 바이트를 덮는지, 오프셋 불일치가 객체를 건드리지 않고 거부되는지, 범위 읽기가 정확히 요청 바이트만 반환하는지, 선언 다이제스트/길이 불일치가 finalize에서 거부되는지, 부재 객체 삭제가 멱등 성공이면서 divergence를 보고하는지, store가 증명한 capability만 보고하는지, 두 업로드가 물리 키를 공유하지 않는지.

FileserverCrashScenarioMain은 별도 프로세스로 fork되는 진입점이고(§sub-scope 02의 LocalPersistentCrashRecoveryTest가 사용), NfsAmbiguityIntegrationTest·NfsTestEnvironment·PvcCertificationDescriptor는 환경이 있을 때만 도는 자격 검증 fixture다.

51. Negative-space probes — sub-scope 06

  • 8.1 reachability: 세 main 타입 모두 leaf 밖 참조 0(147-... §8.1). FilesystemCsvExportAdapterpublic class인데(다른 것은 package-private/final) 외부 참조가 없으므로 가시성이 필요보다 넓다 — 기록만 한다.
  • 8.2 계약 ↔ 구현: payload 계층의 잔여 경로 연산 선언과 실제 호출 지점 일치(§48).
  • 8.3 중복 mechanism: R1 legacy 인코딩과 R2 스트리밍 인코더 — 별개 포트, 별개 selector, 공유 없음(§49).
  • 8.4 문서/커버리지: 크래시 지점 enum 전수 순회(§50), 두 발행 전략의 공통 계약 상속(§50).

52. Sub-scope 06 findings backlog

우선순위 finding reachability
없음. payload 계층은 잔여 위험을 선언하고 감쌌고, 인코더의 상한·수식 정책·취소는 전부 값으로 관측 가능하며, testkit은 크래시 지점을 열거해 전수 검증한다

53. Sub-scope 06 완료 조건

  • denominator 15 / 15 FULL_READ (147-... OWNED FILES) — main 1,479 LOC 전수 판독
  • §8.1~§8.4 네 종 probe 수행
  • 실행 probe 불필요 — 판정 지점이 정적으로 결정 가능
  • 소스 미변경

54. 모듈 원장 대조

§0의 denominator 119를 하위 범위 실측과 대조한다.

# 하위 범위 main test 근거
1 governance / config / activation 7 2 13 (governance 4 포함) 142
2 control plane + record codec + recovery verifier 3 3 6 143
3 publication 19 7 26 144
4 platform/local IO · gateway · store/publisher 22 8 30 145
5 platform/local 실패·probe·health·orphan + verification + security + audit 24 5 29 146
6 payload · CSV · testkit 3 12 15 147
합계 78 37 119
  • main 78 (12,707 LOC), test 37 (12,043 LOC), governance 4. 총 119 tracked files.
  • unclassified 0, structural-only 0, excluded 0. 6개 하위 범위 모두 FULL_READ.

55. 모듈 findings 종합

우선순위 개수 항목
P2 3 §4·§39 README:105의 "노출된 setting도 bean도 없다"가 audit·health·reaping·quota 넷에 대해 사실과 다름 (port 구현 8, bean 8) · §40 ScriptableContentPolicy가 BOM·NUL·선행 주석으로 우회됨 (실행 probe 3건)
P3 5 §5 R1의 ignoreUnknownFields 부재 · §5 R1/R2 루트 경로 규칙 비대칭 · §6 문서의 기본값 위치·test 목록 오류 · §32 발행 rename의 경로 기반 연산 · §34 maxBorrowedBytes의 서술과 실제 결합 부재
P3/기록 1 §43 실패 분류의 예외 메시지 텍스트 의존

결함이 없는 하위 범위가 셋이다(02·03·06). 이 leaf의 코드 품질은 지금까지 분석한 모듈 중 가장 높은 축에 속한다 — canonical 왕복 검증, 인접 전이 행렬, 서술자 상대 파일시스템 접근, 크래시 지점 전수 순회, fail-closed 검증 합성이 모두 실제로 구현돼 있고 test가 그것을 성질로 고정한다.

반복된 형태는 둘이다.

  1. 문서가 코드보다 좁게 또는 넓게 말한다. README의 guarantee-boundary 문단이 같은 저장소가 만드는 bean을 "없다"고 하고(§4·§39), 기본값의 위치와 test 목록이 어긋나며(§6), 계측의 javadoc이 존재하지 않는 결합을 서술한다(§34). 코드는 대체로 옳고 서술이 뒤처졌다.
  2. 자기 규칙의 예외가 선언될 때와 그렇지 않을 때. LocalPersistentPayloadOperations는 JDK가 서술자 상대 원시연산을 주지 않는 세 곳을 먼저 선언하고 identity 검사로 감쌌다(§48). AtomicMoveContentPublisher의 발행 rename은 같은 성격의 예외인데 선언되지 않고, 보호는 이 모듈이 스스로 "근사에 불과하다"고 적은 사전검사 하나다(§32). 규칙이 아니라 예외를 다루는 방식이 두 곳에서 다르다.

56. 모듈 완료 조건

  • denominator 119 / 119 FULL_READ — 6개 하위 범위 전부 COMPLETE(§54)
  • 하위 범위마다 §8.1~§8.4 네 종 negative-space probe 수행, 증거는 evidence/raw/141147
  • 정적으로 결정 불가한 지점을 실행 probe로 확정: 146(scriptable 탐지 우회 3건)
  • sub-scope 01에서 제기한 P2를 sub-scope 05에서 port 구현·bean 생성 지점으로 확정 — 이월과 종결을 원장에 남김
  • 임시 probe class 1개 추가 후 제거, git status --short = 0, 소스 미변경

57. 실행 검증과 분석 환경 제약

HEAD에서 focused suite를 돌린 결과와 그 해석을 남긴다(evidence/raw/148-fileserver-suite-verification.txt).

컨테이너 기본 로케일에서 :adapter:outbound:fileserver:test398 tests / 1 failed / 3 skipped로 끝난다. 실패한 것은 LocalPersistentPayloadOperationsTest.inspectsLegacyRootArtifactByBoundedNoFollowStreamingWithoutRewritingIt() 하나이고, 원인은 다음이다.

java.nio.file.InvalidPathException: Malformed input or input contains unmappable
  characters: 월간 export -- legacy 01.csv
    at java.base/sun.nio.fs.UnixPath.encode(UnixPath.java:129)
    at LocalPersistentPayloadOperationsTest.java:331

분석 컨테이너의 로케일이 POSIX이고 sun.jnu.encoding=ANSI_X3.4-1968이라, test fixture가 자기 경로를 만드는 단계(Path.resolve, 331행)에서 한국어 파일명을 인코딩하지 못한다. adapter production 코드는 실행되지도 않는다.

같은 revision을 LANG=C.UTF-8로 다시 돌리면 BUILD SUCCESSFUL이다. 소스는 한 줄도 바꾸지 않았다.

판정: 분석 환경 제약이지 저장소 결함이 아니다. (JPA scope에서 PostgreSQL TLS lane을 같은 방식으로 분류한 것과 동일한 형태다. 다만 이 leaf는 비-ASCII 소스를 test fixture에 쓰므로, adapter/outbound/identifierbuild.gradle에서 UTF-8 인코딩을 명시적으로 고정한 것과 같은 조치를 이 leaf는 하지 않았다는 점은 기록해 둔다 — 컴파일 인코딩과 런타임 sun.jnu.encoding은 별개 문제이므로 결함으로 올리지는 않는다.)

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 69개 (main 55 · test 13 · 기타 1).

src/adapter/outbound/fileserver/build.gradle
src/config/architecture/modules.json                    (adapter-outbound-fileserver 항목)

main:
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/DurablePublicationRecord.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationCanonicalDigests.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverActivationValidator.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodec.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Config.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Validation.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRecoveryVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestor.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournal.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/PrivateFileManifest.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/PublishedReferenceRecord.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/RoutingFilePublicationAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/StreamingCsvEncoder.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/audit/StructuredAdminAuditAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/audit/StructuredFileserverAuditAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/AmbiguousFilesystemOperationDetector.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/AtomicMoveContentPublisher.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/ContentPublishVerification.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/DefaultPhysicalPathResolver.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/FilesystemFailureClassifier.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalAppendEngine.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalBlockingContentStore.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalCopyContentGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalOrphanScanAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalReconciliationContentProbe.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageCapabilityProbe.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageFailures.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageHealthAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageUsageProbe.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalUploadHandle.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalZeroCopyDownloadGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/MetadataPointerContentPublisher.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/SafeFileChannelFactory.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/SecureDirectoryWalk.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/TransferBufferPool.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/security/RoleBasedFileAccessPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/security/UnenforcedFileAccessPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/FilenamePolicyVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/LengthVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/LocalVerificationContentReader.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/MediaTypeVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/ScriptableContentPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/VerificationCoordinator.java
  src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/VerificationPolicyCombiner.java

test:
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverCrashScenarioMain.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentCrashRecoveryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperationsTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/ContentPublisherTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalAppendMemoryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/ContentStoreContract.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/CrashRecoveryMatrixTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/LargeFileBoundedMemoryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/LocalContentStoreContractTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/MetadataPointerContentStoreContractTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/NfsAmbiguityIntegrationTest.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/NfsTestEnvironment.java
  src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/PvcCertificationDescriptor.java

기타:
  src/build.gradle

해석되지 않은 인용 (9종) — 외부 타입·문서상 약칭 등:
  app-bootstrap/application.yml
  evidence/raw/141-fileserver-module-inventory.txt
  evidence/raw/142-fileserver-config-activation-probes.txt
  evidence/raw/143-fileserver-control-plane-probes.txt
  evidence/raw/144-fileserver-publication-probes.txt
  evidence/raw/145-fileserver-local-io-probes.txt
  evidence/raw/146-fileserver-verification-security-audit-probes.txt
  evidence/raw/147-fileserver-payload-testkit-probes.txt
  evidence/raw/148-fileserver-suite-verification.txt


A09. adapter-outbound-objectstorage

분석 중에는 09-adapter-outbound-objectstorage.md 파일이었다. 920줄.

09 · adapter-outbound-objectstorage

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-outbound-objectstorage
  • canonical state analysisFile: §A09 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/outbound/objectstorage · Gradle :adapter:outbound:objectstorage
  • registry allowed_dependencies: ["application-core", "shared-contract"]
  • registry runtime_memberships: ["sample-portfolio"]
  • coverage ledger: FULL_READ 206 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


상태: COMPLETE
revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
경로: src/adapter/outbound/objectstorage · Gradle: :adapter:outbound:objectstorage

0. Denominator와 coverage ledger

tracked file 206개 — main 147 (14,336 LOC), test 48 + resource 1 (6,753 LOC), 별도 qualification source set 3개 (6 files, 546 LOC), governance 4. 총 약 21.6k LOC.

{ "id": "adapter-outbound-objectstorage",
  "gradle_path": ":adapter:outbound:objectstorage",
  "allowed_dependencies": ["application-core", "shared-contract"],
  "runtime_memberships": ["sample-portfolio"] }

build.gradlestrictTestLanes로 세 개의 별도 source set을 선언한다 — objectStorageMinioContractTest, objectStorageMinioFaultTest, objectStorageAwsQualificationTest. AWS SDK v2 BOM은 Spring Boot BOM이 관리하지 않으므로 모듈 범위로 import되고, 그 이유가 주석에 적혀 있다("keeps the strict-locking blast radius to this module").

패키지 배치(main): s3 26 · control 24 · kernel 23 · config 19 · direct 13 · readiness 8 · maintenance 8 · codec 7 · filesystem 6 · multipart 5 · provider 4 · 루트 4.

하위 범위 원장
# 범위 main test 기타 상태
1 governance + config/** — opt-in · binding compiler · routing · capability 19 5 4 28 COMPLETE
2 control/** — canonical JSON codec + durable record 타입 24 1 25 COMPLETE
3 kernel/** + codec/** — operation kernel · state machine · epoch · key/fingerprint codec 30 9 39 COMPLETE
4 s3/** — provider binding · client policy · async bridge · provider 구현 26 14 40 COMPLETE
5 direct/** + multipart/** — direct transfer · multipart coordinator 18 7 25 COMPLETE
6 filesystem/** + maintenance/** + readiness/** + provider/** + 루트 30 12 1 43 COMPLETE
7 qualification source set 3종 (minio contract / minio fault / aws) 6 6 COMPLETE
TOTAL 147 48 11 206 7 / 7

manifest: evidence/raw/149-objectstorage-module-inventory.txt.


1. Sub-scope 01 범위와 denominator

내부 상태: COMPLETE — 28 / 28 FULL_READ 범위: governance 4 + config/** main 19 + 전용 test 5 역할: app.object-storage비활성 기본값에서 정확한 불변 바인딩으로 컴파일하고, 선택된 provider만 자원을 만들게 하며, 폐기된 alias를 격리한다

manifest와 probe: evidence/raw/150-objectstorage-config-activation-probes.txt.

2. Confirmed — "컴파일이 먼저, 생성은 나중"이 실제 순서다

ObjectStorageProviderContribution이 두 메서드의 계약을 나눈다.

describe must not resolve credentials, create files, clients, threads, or schedulers. create owns cleanup of every partial allocation before it throws; after a successful return the assembler owns the returned lifecycle exactly once.

ObjectStorageCapabilityAssembler.assemble이 그 순서를 지킨다 — compiler.compile(settings)전부 끝난 뒤(:25)에야 선택된 destination을 돌며 contribution.create(provider)를 부른다(:48). 그리고 도중에 실패하면 이미 만든 것을 역순으로 닫는다(:5356). AssembledCapability.close()도 역순이고 AtomicBoolean으로 정확히 한 번만 실행된다.

README의 "Settings compile fully before any selected provider creates a directory, client, thread, scheduler, or credential lookup"이 코드 구조로 성립한다.

컴파일러 자체가 fail-closed다. 비활성이면 빈 바인딩을 돌려주고, 활성인데 provider·destination·default destination 중 하나라도 비면 거부한다. provider마다 describe가 돌려준 서술자와 설정을 대조한다 — providerType 일치, version 일치, maximumObjectBytes가 서술자 상한 이하, chunkBytes가 서술자 상한 이하. chunk는 추가로 1 ≤ chunk ≤ min(maxObject, 16 MiB)이고 Integer.MAX_VALUE를 넘지 못한다. destination은 route token 중복을 거부하고, 요구한 capability를 provider가 SUPPORTED로 신고하지 않으면 거부하며, SCAN_CLEAN을 요구하는데 scanner seam이 없으면 이름을 대며 거부한다.

식별자 검증도 좁다 — canonicalId는 64자 이내, [a-z0-9][a-z0-9_-]*, 소문자, 그리고 0x20–0x7e 밖 문자를 전부 거부한다.

3. Confirmed — README가 "등록되지 않는다"고 적은 것들이 실제로 등록되지 않는다

150-... §8.2의 네 주장을 각각 추적했다.

README 주장 확인
"no direct-grant port is registered" RoutingObjectDirectGrantAdapter는 private 생성자만 가진 빈 클래스이고, leaf 안에서 자기 파일 밖 참조 0(grep exit=1)
"Scanner and privileged purge composition remain separate and empty" ObjectStorageMaintenanceCapabilityConfig는 본문이 없는 @Configuration. scanner는 별도 ObjectStorageScanMaintenanceConfig에 있고 app.object-storage.scan-maintenance.enabled=true로만 켜진다
"filesystem-local-dev is rejected under prod/production" ObjectStorageBindingCompiler:125에 존재
"Mixing any old alias with canonical settings fails startup without echoing values" LegacyObjectStorageActivationGuard가 두 prefix가 동시에 있으면 IllegalStateException을 던지고, 메시지에 값이 없다

마지막 것의 구현이 특히 조심스럽다 — hasPrefixEnumerablePropertySource를 순회해 prefix로 시작하는 이름이 있는지만 보고, 열거 불가능한 source에 대비해 알려진 키 목록으로 fallback한다. 어느 경로에서도 값을 읽지 않는다.

적재 경로는 fileserver와 같다 — AutoConfiguration.imports가 없고(150-... §8.1, exit=1), CaSkeletonApplication의 명시적 @ComponentScandev.caskeleton.adapter.outbound.objectstorage를 목록에 올린다(:76). 그리고 app.object-storage.*는 어느 application.yml에도 없으므로 실효 기본값은 속성 부재다.

4. Confirmed — legacy가 세 겹으로 격리돼 있다

폐기 경로가 셋인데 서로 다른 스위치를 쓰고 서로를 배제한다.

경로 스위치 성격
선호 임시 활성화 app.object-storage.legacy.enabled=true + 명시적 backend ObjectStoragePort(whole-byte[]) 노출
구 alias ca-skeleton.objectstorage.* LegacyObjectStorageActivationGuard 조건, canonical과 혼용 시 실패
채택(adoption) app.object-storage.legacy-adoption.enabled=true raw locator 유지보수 전용, 별도 config 클래스

ObjectStorageBindingCompiler.rejectLegacyOverlap가 legacy filesystem 루트와 canonical provider 루트가 어느 방향으로든 포함 관계면 거부한다. LegacyObjectAdoptionSettingsAPPLY 모드일 때 검토된 manifest 경로와 64자리 SHA-256을 요구하고, batch size 11000, timeout 5분 이내를 강제한다.

legacy runtime은 AutoCloseable holder로 감싸 S3 client 수명을 정확히 소유하고, @Bean(destroyMethod = "close")로 등록된다.

5. P3 — production 판정이 두 개의 리터럴 프로파일 이름에 걸려 있다

CLAUDE.md의 Forbidden 목록에 "local-dev in production"이 있고, 그것을 강제하는 코드는 이것 하나다.

private boolean productionProfileActive() {
  return activeProfiles.stream()
      .map(profile -> profile.toLowerCase(Locale.ROOT))
      .anyMatch(profile -> profile.equals("prod") || profile.equals("production"));
}

이 저장소가 application-prod.yml을 싣고 있으므로 현재 형상에서는 맞는다. 그리고 150-... §8.2c에서 확인했듯 같은 방식으로 production을 판정하는 leaf는 이것 하나뿐이다 — 저장소 전체가 공유하는 production 판별 장치가 없다.

문제는 방향이다. 이것은 거부 검사인데 판정 근거가 허용 목록 두 개다. prd, production-eu, live, prod-apac 같은 이름을 쓰는 fork는 이 검사를 통과하고, filesystem-local-dev가 production에서 조용히 선택된다 — 그 provider는 README가 "R1-only development provider"라고 적은 것이다. 실패는 startup이 아니라 데이터가 로컬 디스크에 쌓인 뒤에 드러난다.

판정: P3. 이 저장소 형상에서는 도달하지 않는다. 기록하는 이유는 (a) CLAUDE.md가 금지 항목으로 명시했고 (b) 강제 수단이 두 문자열이며 (c) fork가 프로파일 이름을 바꾸는 것은 평범한 일이기 때문이다. 수정은 production 판별을 명시적 설정(예: app.object-storage.allow-local-dev=true를 요구)으로 뒤집는 것 — 이름이 아니라 의도를 묻는 형태다.

6. P3/기록 — readiness registry가 build의 test 입력인데 leaf 소스가 그 파일명을 참조하지 않는다

build.gradle:4546docs/registries/object-storage-readiness.yamltest task의 inputs.file로 선언한다. 그 파일의 헤더는 소유 test 둘을 이름으로 적는다.

# Repository owner test: dev.caskeleton.bootstrap.contract.ContractRegistrySchemaGovernanceTest
# Semantic owner test: dev.caskeleton.adapter.outbound.objectstorage.readiness.ObjectStorageReadinessRegistryTest

그런데 leaf 소스에서 object-storage-readiness라는 문자열을 검색하면 매치가 없다(150-... §8.4c, exit=1). 즉 semantic owner test는 파일명을 상수로 갖지 않고 다른 방식(경로 조립 등)으로 찾는다. 실제 결합 여부는 readiness 패키지를 읽는 sub-scope 06에서 확정한다 — 여기서는 이월 항목으로만 남긴다.

7. Confirmed — 후보로 본 unguarded split은 값 타입이 막고 있다

RoutingObjectReadAdapter.loadreference.canonicalText().split("\\.", -1)[1]로 route token을 꺼낸다. 인덱스 검사가 없어 처음에는 ArrayIndexOutOfBoundsException 후보로 봤다.

ObjectReference를 확인한 결과 생성자가 ObjectIdentitySupport.requireRouted(canonicalText, "osr1")로 형태를 강제하므로, 유효하게 만들어진 참조에는 항상 route 구획이 있다(150-... §8.4d). 결함이 아니다.

8. Negative-space probes — sub-scope 01

  • 8.1 reachability: auto-configuration 등록 metadata 0, 적재는 명시적 component scan, 두 selector 모두 application.yml에 부재(속성 부재가 실효 기본값).
  • 8.2 계약 ↔ 구현: describe/create의 부작용 계약과 assembler의 실제 호출 순서(§2). README의 네 가지 "등록되지 않는다" 주장 전수 확인(§3).
  • 8.2b 조건부 형제: production 판정이 이 leaf에만 있고 저장소 공용 장치가 없음(§5).
  • 8.3 중복 mechanism: legacy 경로 셋이 서로 다른 스위치를 쓰고 겹침을 명시적으로 거부(§4).
  • 8.4 문서/빌드 drift: readiness registry의 build 입력 선언과 leaf 소스의 참조 부재(§6, sub-scope 06으로 이월).

9. Sub-scope 01 findings backlog

우선순위 finding reachability
P3 filesystem-local-dev production 거부가 prod/production 두 리터럴에만 걸려 있다 — 다른 이름을 쓰는 fork는 R1 개발용 provider를 production에서 받는다 프로파일 이름을 바꾼 fork
P3/기록 readiness registry가 build의 test 입력인데 leaf 소스에 그 파일명 참조가 없다 — 실제 결합은 sub-scope 06에서 확정 이월

10. Sub-scope 01 완료 조건

  • denominator 28 / 28 FULL_READ (150-... OWNED FILES)
  • §8.1~§8.4 네 종 probe 수행, README의 네 가지 조립 주장을 각각 코드로 추적
  • 후보 finding 1건(unguarded split)을 값 타입 검증으로 추적해 결함 아님으로 판정(§7)
  • 소스 미변경

11. Sub-scope 02 범위와 denominator

내부 상태: COMPLETE — 25 / 25 FULL_READ 범위: control/** main 24 (2,470 LOC) + 전용 test 1 (339 LOC) 역할: 열한 종의 durable 제어 레코드를 닫힌 sealed 계열로 두고, canonical-json-v1 + 외곽 SHA-256 봉투로 인코딩하며, 불변식을 레코드 생성자에서 강제한다

manifest와 probe: evidence/raw/151-objectstorage-control-probes.txt.

12. Confirmed — 계열이 닫혀 있고 스키마가 fail-closed다

ObjectControlRecord는 열한 개 구현만 허용하는 sealed interface이고, javadoc이 규칙을 적는다 — "Unknown families and schemas fail closed." codec의 payload(...)가 그 계열에 대해 exhaustive switch를 쓰므로, 새 레코드를 추가하면 컴파일이 강제로 codec을 갱신하게 만든다.

스키마 버전은 ControlRecordSupport.headerschemaVersion != 1을 거부한다 — "only control schema version 1 is writable". 더 새로운 스키마를 만나면 덮어쓰지 않고 UnsupportedObjectControlSchemaException으로 격리한다("A newer or unknown durable schema that must be quarantined rather than overwritten").

objectstorage.control 패키지를 leaf 밖에서 참조하는 코드는 0이다(151-... §8.1, exit=1). CLAUDE.md의 "control-record types leaking into application-core" 금지가 가시성으로 성립한다.

13. Confirmed — canonical 표현이 "우리가 쓴 것과 바이트가 같은가"로 강제된다

CanonicalJsonReader는 관용을 두지 않는다.

  • 필드 순서 고정: field(expectedName)이 읽은 이름과 기대 이름을 비교한다 — 재배열은 실패.
  • 공백 불허: expect(char)가 정확히 그 문자만 소비한다. 공백을 건너뛰는 코드가 없다.
  • 이스케이프 두 개만: \"\\ 외의 이스케이프는 실패.
  • printable ASCII만: 0x200x7e 밖 문자는 reader·writer·ControlRecordSupport 세 곳 모두에서 거부.
  • 숫자 정규형: 선행 0(00, 01)과 -0을 거부.
  • 후행 콘텐츠 불허: end()cursor != input.length()면 실패.

reader가 new String(bytes, UTF_8)로 관용 디코딩하는 것은 그 자체로는 malformed 바이트를 U+FFFD로 바꾸지만, U+FFFD(0xFFFD)는 0x7e를 넘으므로 printable ASCII 검사에서 걸린다. 즉 관용 디코딩이 뚫리지 않는다 — fileserver R1 저널(sub-scope 03, 08번 문서 §26)에서 같은 관용 디코딩이 열려 있던 것과 대비된다.

봉투도 이중으로 잠긴다. decodeUnchecked가 Base64를 디코딩한 뒤 다시 인코딩해 문자열이 같은지 확인하고(alias 거부), payload의 SHA-256을 MessageDigest.isEqual(상수시간)로 비교한다. 크기 상한이 계열별로 셋이다 — 봉투 64 KiB, terminal receipt 16 KiB, part 4 KiB — 그리고 encode·decode 양쪽에서 enforceFamilySize가 적용된다.

14. Confirmed — 레코드가 값을 믿지 않고 관계를 다시 계산한다

ObjectOperationRecord의 compact 생성자가 대표적이다. 넘겨받은 policySnapshotDigest를 그대로 쓰지 않고 스냅샷에서 다시 계산해 대조한다 — "policySnapshotDigest does not match the snapshot". expectedContentIdentity가 있으면 얼어붙은 정책 상한을 넘는지도 본다.

상태 짝도 강제된다.

  • (pendingEffect == null) != (effectCertainty == NOT_SENT) → "pending effect and certainty do not agree"
  • 미해결 pending effect가 있는데 새 것을 만들면 → "an unresolved pending effect already exists"
  • DATA_UPLOADED로 전진하려면 업로드 증거가 확인돼 있어야 함
  • terminal 전이는 ABORTED/QUARANTINED/FAILED만 허용

ObjectPublicationHandoffRecord는 lease를 fence와 함께 다룬다 — fence는 1 이상, released && abortAuthorized 동시 참 금지, 그리고 쓰기 시점에 이미 만료된 활성 lease를 거부한다("active handoff lease is expired at write time"). claimant는 지문(hexDigest)으로만 저장된다.

ObjectStagedObjectRecord는 scan 증거의 짝을 강제한다 — (scanOperationId == null) != (scannerPolicyRevision == null)이면 "scan verdict evidence is incomplete", 그리고 스캔은 integrityVerified && publicationRequirement == SCAN_CLEAN일 때만 시작할 수 있다.

ObjectControlStore 인터페이스에는 list가 없다. javadoc이 그 부재를 명시한다 — "exact-lookup/create/CAS control storage. LIST is deliberately absent." 열거가 없으면 제어 평면을 훑어 다른 테넌트의 키를 발견하는 경로가 구조적으로 없다.

15. Negative-space probes — sub-scope 02

  • 8.1 reachability: control 패키지의 leaf 밖 참조 0(§12).
  • 8.2 계약 ↔ 구현: canonical 주장과 reader/writer의 실제 거부 목록 대조(§13). 관용 UTF-8 디코딩이 ASCII 검사로 닫히는지 확인.
  • 8.2b 봉투 무결성: Base64 재인코딩 대조 + 상수시간 digest 비교 + 계열별 크기 상한(§13).
  • 8.3 닫힌 계열: sealed interface와 exhaustive switch가 새 레코드 추가 시 codec 갱신을 강제(§12). ObjectControlStorelist 부재(§14).
  • 8.4 불변식: 레코드 생성자가 digest를 재계산하고 상태 짝을 강제하는 지점 전수 확인(§14).

16. Sub-scope 02 findings backlog

우선순위 finding reachability
없음. canonical 강제가 reader·writer·봉투 세 겹이고, 레코드 불변식이 값이 아니라 관계를 검증하며, 열거 API가 존재하지 않는다

17. Sub-scope 02 완료 조건

  • denominator 25 / 25 FULL_READ (151-... OWNED FILES) — main 2,470 LOC 전수 판독
  • §8.1~§8.4 네 종 probe 수행
  • 관용 UTF-8 디코딩 후보를 ASCII 검사로 추적해 결함 아님으로 판정
  • 소스 미변경

18. Sub-scope 03 범위와 denominator

내부 상태: COMPLETE — 39 / 39 FULL_READ 범위: kernel/** 23 (1,463 LOC) + codec/** 7 (539 LOC) + 전용 test 9 역할: provider SDK 없이 예약 → 보류 효과 → 확인 → 단계 전진을 정확한 CAS 위에서 돌리고, 모든 네임스페이스 키를 단일 인코더로 만든다

manifest와 probe: evidence/raw/152-objectstorage-kernel-codec-probes.txt.

19. Confirmed — 다섯 개의 닫힌 전이표가 있고 terminal이 진짜 terminal이다

ObjectOperationStateMachine이 publication·scan·reference·direct-grant·multipart 다섯 계열의 전이를 각각 switch로 적는다. terminal 처리가 계열마다 명시적이다.

계열 terminal 처리
publication current.terminal()이면 즉시 거부
scan case CLEAN, MALICIOUS -> false; NOT_REQUIRED는 어디로도 못 감
reference case PURGED -> false (PUBLISHED → RETIREMENT_PENDING → RETIRED → PURGE_ELIGIBLE → PURGED)
direct grant terminal 집합 4종을 먼저 계산해 !currentTerminal 요구
multipart terminal 집합 5종에 대해 동일

뒤 둘은 branch 전이(EXPIRED/ABORTED/FAILED/CORRUPT)를 별도로 허용해, 정상 사슬 어디서든 실패로 빠질 수 있되 terminal에서는 나올 수 없게 한다.

requireNextRevision(current, next)next == current + 1을 강제한다 — revision은 건너뛰지도 되돌아가지도 못한다. ObjectOperationStateMachineTest가 그 셋을 이름으로 고정한다: publicationFollowsScanFreeAndScanRequiredPaths, terminalOutOfOrderAndStaleRevisionTransitionsFailClosed, independentStateFamiliesDoNotImplyEachOther.

20. Confirmed — 응답 유실을 "의도를 먼저 적는" 방식으로 다룬다

StagedObjectPublicationKernel.stage의 순서가 핵심이다.

  1. operations.reserve(...) — 제어 저장소에 조건부 create. 충돌하면 기존 레코드의 requestFingerprint를 비교해 CONFLICT / REPLAY_TERMINAL / REPLAY_NON_TERMINAL로 분류한다.
  2. pendingEffect == null이면 markEffectSent(...)외부 mutation 전에 의도를 durable하게 적는다 — kind(DATA_PUT), attemptId, 대상 증거 해시, 원하는 상태, precondition(create-if-absent), 요청 증거 다이제스트.
  3. resolveOrCreate(providerOperation, producer) — provider 호출.
  4. 성공하면 confirmEffect(...), 그 다음에야 advancePublication(DATA_UPLOADED).

PendingObjectEffect의 javadoc이 그 성격을 못박는다 — "Bounded, non-secret exact intent persisted before external mutation". 모든 필드가 길이 제한이 있고 대상은 해시로만 적힌다.

ObjectOperationKernel.replace는 read → stored.record().equals(expected) 비교 → compareAndSet(key, mutation(stored.version(), replacement)) 순서다. 즉 낙관적 비교와 저장소 CAS를 겹쳐 쓴다. test pendingEffectIsDurableBeforeIoAndResponseLossRemainsPhaseSpecificeveryPendingMutationIsResolvedOnceWithoutBlindMutationReplay가 이 성질을 고정한다 — 후자의 이름이 규칙을 그대로 말한다.

21. Confirmed — 모든 키가 단일 인코더에서 나오고 route를 벗어날 수 없다

ObjectControlKeyCodecObjectDataKeyCodec이 각각 "Sole encoder"를 자칭하고, 저장소에서 "control/v1/"·"data/v1/" 리터럴은 이 두 파일에만 있다(152-... §8.3). 키 형태는 control/v1/<family>/<route>/<shard>/<identity>이고 shard는 identity의 SHA-256 앞 두 자리다.

route 격리가 구조적이다 — requireMatchingRoute(route, routedIdentity)가 routed identity의 route 구획을 파싱해 현재 route와 다르면 거부한다("routed identity belongs to a different route"). reference·session·stage handle 키 모두 이 검사를 지난다.

핸들 계열도 접두사로 분리된다 — osh1(stage), osu1(direct upload), osm1(multipart), osv1(version), osr1(reference). ObjectNamespaceCodecTest.referenceAndHandleFamiliesRemainSeparated가 그 분리를 고정하고, dataKeyApiHasNoRawNameStringParameterAPI 서명 자체에 raw 이름 문자열이 없음을 단언한다.

CrockfordBase32는 소문자 정규 알파벳(0123456789abcdefghjkmnpqrstvwxyz — I·L·O·U 제외)을 쓰고, 인코딩 후 남은 값이 있으면 거부한다("base32 output length is too small") — 잘림을 조용히 넘기지 않는다.

test에 property-based 검사가 있다 — routeParserRejectsArbitraryNonCanonicalText(@ForAll String candidate)(jqwik), namespaceRejectsAliasesAndTraversalInputs, 그리고 fingerprint codec에는 golden vector가 고정돼 있다(canonicalIntentHasAFrozenGoldenVector, sameIntentIsStableAndEverySemanticChangeChangesTheFingerprint).

policySnapshotCodecIsCanonicalAndContainsNoCredentialSurface는 정책 스냅샷에 자격증명 표면이 없음을 test로 고정한다.

22. P3/기록 — 보류 효과 전이가 updatedAt을 전진시키지 않는다

ObjectOperationKernel의 두 메서드가 새 시각 대신 기존 값을 쓴다(152-... §8.2d).

markEffectSent    current.withPendingEffect(effect, current.updatedAt())
markResponseLost  current.withEffectCertainty(INDETERMINATE, current.updatedAt())

confirmEffectadvancePublication·terminatenow를 받는다. 즉 의도를 적은 시각과 응답 유실을 기록한 시각이 durable 레코드에 남지 않는다 — revision은 올라가지만 updatedAt은 이전 단계의 값 그대로다.

기능상 문제는 없다. revision이 순서를 주고, lease 만료 판정은 ObjectPublicationHandoffRecord가 자기 leaseExpiresAt로 따로 한다. 다만 "언제 이 mutation을 보냈는가"는 응답 유실 조사에서 가장 먼저 묻는 값이고, 지금은 레코드에서 답할 수 없다. P3/기록.

23. Negative-space probes — sub-scope 03

  • 8.1 reachability: kernel·codec 패키지의 leaf 밖 참조 0(exit=1).
  • 8.2 계약 ↔ 구현: 다섯 전이표의 terminal 처리 전수 확인(§19). 보류 효과의 기록-호출-확인 순서를 호출 지점으로 추적(§20).
  • 8.2b 조건부 형제: confirmEffect·advancePublicationnow를 받고 markEffectSent·markResponseLost는 받지 않음(§22).
  • 8.3 중복 mechanism: 키 리터럴이 두 인코더에만 존재하고 route 격리가 파싱으로 강제됨(§21).
  • 8.4 test 커버리지: 상태 기계·kernel·codec 각각에 이름이 규칙을 말하는 test가 있고, property-based 검사와 golden vector가 포함됨.

24. Sub-scope 03 findings backlog

우선순위 finding reachability
P3/기록 markEffectSent·markResponseLostupdatedAt을 전진시키지 않아, 의도를 적은 시각과 응답 유실 시각이 durable 레코드에 남지 않는다 응답 유실 조사

25. Sub-scope 03 완료 조건

  • denominator 39 / 39 FULL_READ (152-... OWNED FILES)
  • §8.1~§8.4 네 종 probe 수행
  • 실행 probe 불필요 — 판정 지점이 정적으로 결정 가능
  • 소스 미변경

26. Sub-scope 04 범위와 denominator

내부 상태: COMPLETE — 40 / 40 FULL_READ 범위: s3/** main 26 (3,581 LOC) + 전용 test 14 (약 2,300 LOC) 역할: AWS SDK v2를 이 패키지 안에 가두고, 클라이언트 정책·바인딩·조건부 제어 저장소·비동기 브리지를 컴파일 시점에 전부 검증된 값으로 만든다

manifest와 probe: evidence/raw/153-objectstorage-s3-probes.txt.

27. Confirmed — SDK 타입이 production에서 leaf를 벗어나지 않는다

software.amazon.awssdk를 참조하는 main 파일은 leaf 안에 19개이고, 그중 16개가 s3/**이다. 밖의 셋은 ObjectStorageConfig·S3ObjectStorageAdapter(둘 다 루트의 legacy 어댑터)와 config/ObjectStorageCapabilityConfig(legacy runtime이 S3 client를 만드는 지점)뿐 — 전부 README가 "deprecated compatibility only"로 선언한 경로다.

leaf 밖에서 SDK를 참조하는 파일은 아키텍처 test 카탈로그와 빌드 파일뿐이다(153-... §8.1) — GraphQlReturnTypePolicy, WebForbiddenTypeCatalog, NotificationArchitectureTest, 그리고 application-coreObjectStorageArchitectureContractTest. 즉 production 코드에서의 유출은 없고, 유출 금지가 다른 leaf의 금지 타입 목록으로도 지켜지고 있다.

28. Confirmed — 클라이언트 정책이 시간 예산의 정합성을 검사한다

S3ClientPolicy.validate()가 개별 값의 양수 여부를 넘어 값들 사이의 관계를 본다.

  • per-attempt timeout < parent call timeout
  • 다섯 전송 timeout(connection·TLS·acquire·read·write)이 모두 per-attempt timeout 이하
  • retryBaseDelay ≤ retryMaximumBackoff
  • 재시도 최악 예산이 부모 호출 예산 안에 드는지: attemptTimeout × maxAttempts + maxBackoff × (maxAttempts 1) ≤ apiCallTimeout 그리고 그 곱셈은 ArithmeticException을 잡아 "retry budget overflows"로 거부한다.

즉 "재시도를 다 해도 부모 예산을 못 넘는다"가 설정 검증으로 강제된다 — 설정만 보고는 알 수 없는 종류의 모순이다.

엔드포인트도 좁다 — scheme은 http/https만, userinfo·query·fragment 금지, 그리고 평문 AWS 엔드포인트 금지(http + *.amazonaws.com → 거부). 정적 자격증명은 access/secret가 둘 다 있거나 둘 다 없어야 한다. toString()은 자격증명을 [REDACTED]로 대체한다.

S3AsyncClientFactoryTest가 이 규칙들을 이름으로 고정한다 — rejectsMissingNonPositiveAndContradictoryTimeoutPoolAndRetryPolicy, rejectsUnsafeEndpointAndPartialStaticCredentialsWithoutExposingSecrets.

29. Confirmed — provider 타입마다 신원 규칙이 다르고, 둘 다 좁다

S3ProviderBinding.compileAWS_S3_GENERAL_PURPOSEMINIO_COMMUNITY_2024_01_16을 다르게 검증한다.

AWS MinIO
expectedOwner 정확히 12자리 숫자 필수 설정하면 거부("cannot claim an AWS expected owner")
endpoint override 금지("not part of the qualified profile") 필수, canonical HTTPS만
addressing virtual-hosted 강제 virtual-hosted 또는 path-style
credentials default-chain 허용 환경변수 참조 필수("MinIO requires explicit environment credential references")

그리고 provider 타입과 무관하게 autoCreateBucket || publicAcl이면 거부한다 — "runtime provisioning and public ACLs are forbidden". 런타임이 버킷을 만들거나 공개 ACL을 붙이는 경로가 설정 단계에서 닫힌다.

30. Confirmed — mutation의 불확실성이 보존된다

S3ProviderErrorMapper.map(failure, mutation)의 첫 분기가 규칙이다. AwsServiceException아니면(즉 서버가 답하지 않았으면) 그리고 mutation이면 → Failure.INDETERMINATE, authoritative=false. 서버가 답한 경우에만 error code/status로 분류하고 authoritative = normalized != INDETERMINATE로 표시한다.

상태 코드 매핑의 기본값도 같은 방향이다 — 알 수 없는 status는 mutation ? INDETERMINATE : UNKNOWN. 즉 "실패했으니 재시도"라는 순진한 독법이 구조적으로 불가능하다.

S3ConditionalObjectControlStore도 같은 규칙을 제어 평면에 적용한다 — INDETERMINATE면 즉시 실패하지 않고 정확한 GET으로 실제 상태를 읽어 기대값과 비교한 뒤 결정한다. test droppedCreateResponseResolvesByExactGetAndDigestComparisonstaleWriterConflictAndCorruptControlNeverBecomeAbsence, exact404IsTheOnlyAbsentRead가 그 세 갈래를 고정한다. 마지막 이름이 특히 중요하다 — 부재로 해석되는 유일한 신호가 정확한 404다.

31. Confirmed — 논리 다이제스트와 provider 체크섬을 분리해 둘 다 대조한다

S3ChecksumPolicy가 사용자 메타데이터 키 ca-logical-sha256에 논리 SHA-256을 넣고, provider의 네이티브 checksumSHA256도 함께 요청한다. requireMatchingEvidence둘 다 기대값과 같은지 확인하고 하나라도 어긋나면 CONTENT_MISMATCH다.

S3ObjectEvidenceMapper.fromHead는 그 위에 세 가지를 더 요구한다 — content length가 바인딩 상한 안, serverSideEncryption == AES256, ETag 존재. 그리고 provider version id와 ETag는 HeadEvidence에 담겨 adapter-private로 남는다(S3ConditionalRequestMapperifMatch/versionId 조건으로만 쓴다).

32. Confirmed — 비동기 브리지가 단일 구독·유계 버퍼·역압을 지킨다

S3AsyncRequestBodyBridgesubscribed.compareAndSet(false, true)로 단일 구독을 강제하고, downstream 수요를 기다린 뒤에야 한 청크를 보유한다. 누적 길이가 선언 길이를 넘으면 즉시 ObjectChunkWriteException, 완료 시 관측 identity가 기대와 다르면 provider 실패다. 취소와 예산 만료를 매 청크 경계에서 확인한다.

S3AsyncResponseBodyBridge는 SDK 콜백을 논블로킹으로 두고 동기 소비자를 adapter 소유 worker에서 호출한다. S3ConditionalObjectControlStoreBoundedControlTransformer도 누적 바이트가 64 KiB를 넘으면 중단한다.

test가 이 성질을 직접 잡는다 — streamsOnceOffTheSubscriberThreadWithOneChunkOfProducerLead, cancellationAndDigestMismatchFailWithoutProducerReplay, truncatedAndOversizedSdkChunksFailClosed.

33. Negative-space probes — sub-scope 04

  • 8.1 reachability: SDK 참조를 leaf 안팎으로 전수 조사(§27). 밖은 아키텍처 test 카탈로그뿐.
  • 8.2 계약 ↔ 구현: 정책의 시간 예산 상호 검증(§28), provider 타입별 신원 규칙(§29).
  • 8.2b 조건부 형제: AWS와 MinIO가 같은 필드에 대해 반대 방향 규칙을 갖고 둘 다 강제됨(§29).
  • 8.3 중복 mechanism: 논리 다이제스트와 provider 체크섬을 의도적으로 이중화하고 둘 다 대조(§31) — 중복이 아니라 교차 검증.
  • 8.4 불확실성 보존: 비권위적 실패의 분류와 정확한 GET을 통한 해소(§30). 단일 구독·유계 버퍼(§32).

34. Sub-scope 04 findings backlog

우선순위 finding reachability
없음. SDK가 패키지에 갇혀 있고, 정책이 값이 아니라 값들 사이의 관계를 검증하며, mutation 불확실성이 분류에서 보존되고 정확한 GET으로만 해소된다

35. Sub-scope 04 완료 조건

  • denominator 40 / 40 FULL_READ (153-... OWNED FILES) — main 3,581 LOC 전수 판독
  • §8.1~§8.4 네 종 probe 수행
  • 실행 probe 불필요 — 판정 지점이 정적으로 결정 가능하고 test가 각 성질을 이름으로 고정
  • 소스 미변경

36. Sub-scope 05 범위와 denominator

내부 상태: COMPLETE — 25 / 25 FULL_READ 범위: direct/** 13 + multipart/** 5 (main 18, 1,988 LOC) + 전용 test 7 역할: 브라우저가 provider와 직접 주고받는 bearer grant의 상태 기계와, part 원장·완료 검증

manifest와 probe: evidence/raw/154-objectstorage-direct-multipart-probes.txt.

37. 이 sub-scope의 설계 — 비밀은 durable하지 않고, 승인은 명시적으로 닫힌다

durable record가 비밀을 담지 않는다는 것이 출발점이다. DirectTransferSessionRecord의 한 줄 javadoc이 그것이다 — "Durable non-secret direct-transfer session state; bearer material is deliberately absent." 저장되는 것은 generation·제약 다이제스트·서명 시각·만료·credential revision·reference revision뿐이고, presigned URI와 서명 헤더는 process-local 캐시에만 남는다. 프로세스가 재시작하면 이미 발급된 grant는 재현되지 않고 "issued direct grant bearer material is unavailable after process restart"로 명시적으로 실패한다 — 조용히 새로 서명해서 두 번째 bearer를 만드는 대신이다.

상태 전이도 CAS로 순서가 고정된다. SESSION_RESERVED → GRANT_PREPARED → GRANT_ISSUED → DATA_UPLOADED이고, test 이름이 그 순서를 그대로 못박는다 — preparedCasPrecedesSigningAndIssuedCasPrecedesReturningTheBearerGrant. 서명 전에 prepared가 durable해야 하고, bearer를 반환하기 전에 issued가 durable해야 한다.

multipart 쪽에서 가장 흥미로운 것은 완료 시점의 admission drain이다. DirectMultipartCompletionVerifier.requireAdmissionDrained는 provider가 "controlled ingress가 비었다"고 권위 있게 말해 주지 않으면, 마지막 grant 만료 + 검증된 시계 오차 + 최대 in-flight 지평 이 지나기 전에는 완료를 거부한다. 이미 발급된 part PUT이 아직 날아가고 있을 수 있기 때문이다. test 이름이 completionHorizonRejectsWhileIssuedPartRequestsMayStillArrivelateGrantAdmissionRejectsAfterCompletionFence — 양방향 모두 고정돼 있다.

part 원장은 provider의 LIST 순서에 의존하지 않는다. MultipartPartLedger.ordered(n)은 1..n을 정확한 키로 하나씩 읽고 하나라도 없으면 "ledger has a gap"이다. record(...)는 같은 증거면 REPLAYED, 다른 증거면 conflict — 재시도가 원장을 다시 쓰지 못한다. 그리고 DirectMultipartCompletionVerifier.requireExactLedger가 클라이언트가 제출한 receipt token 목록과 원장을 위치까지 일치시키고 논리 길이 합계를 기대값과 대조한다.

로그 유출 차단도 한 곳에 모여 있다 — PresignedGrantRedactor.redact(URI)는 인자를 아예 쓰지 않고 [REDACTED_PRESIGNED_URI]를 돌려주며, 헤더는 키 이름만 정렬해 노출한다. test가 bearerUriAndSignedValuesAreNeverRendered로 잡는다.

38. P2 — 직접 multipart의 마지막 part는 grant를 받을 수 없다

S3ClientPolicy.requirePartSize(long partBytes, boolean finalPart)마지막이 아닌 part에만 5 MiB 하한을 적용한다(MINIMUM_NON_FINAL_PART_BYTES = 5 * 1024 * 1024). S3의 실제 규칙과 같다.

leaf 안에 이 정책의 호출 지점이 셋인데, 마지막 하나만 다르다.

호출 지점 finalPart 인자
MultipartUploadPlan.partBytes:61 partNumber == partCount — 계산된 값
S3ManagedMultipartProvider:80 finalPart — 호출자가 넘긴 값
DirectMultipartCoordinator.createPartGrant:163 false 하드코딩

그리고 PartUploadGrantRequest에는 "이것이 마지막 part"라는 필드가 없다(operationKey · sessionId · partNumber · exactPartLength · expectedPartDigest · requestedTtl · budget · cancellation). 그러므로 coordinator가 그 사실을 알아낼 방법도 없다 — maximumParts()는 상한일 뿐 실제 part 수가 아니고, 실제 수는 completeMultipart에서 request.partTokens().size()로 비로소 정해진다.

결과. DirectMultipartUploadPort.createPartGrant는 5 MiB 미만의 part에 대해 항상 "S3 multipart part size is outside the supported range"로 거부한다. 즉

  • 총 크기가 5 MiB 미만인 객체는 직접 multipart로 전혀 올릴 수 없다(part 1개 = 마지막 part).
  • 고정 part 크기 + 나머지라는 통상적인 클라이언트 분할(예: 12 MiB를 5+5+2로)은 마지막 grant 요청에서 실패한다. 성공하려면 클라이언트가 모든 part를 5 MiB 이상으로 재분할해야(5+7) 하는데, 그 요구는 port 계약 어디에도 적혀 있지 않다.

같은 파일이 MultipartUploadPlan에서 마지막 part 구분을 정확히 계산하고 있으므로 규칙을 모르는 상태가 아니다. 직접 경로에서만 정보가 요청 타입에 실려 오지 않아 보수적인 상수로 대체된 것이다. 판정: P2. 수정은 PartUploadGrantRequest에 마지막 part 표시를 추가하고 그 값을 넘기는 것, 또는 start 요청에 정확한 part 수를 고정해 partNumber == exactPartCount로 유도하는 것이다.

test는 이 경계를 건드리지 않는다 — DirectMultipartCoordinatorTest의 세 케이스는 모두 5 MiB 이상 part만 쓴다. 반대로 S3SdkApiCharacterizationTest:93-94requirePartSize(MIN, false)requirePartSize(MIN - 1, true) 둘 다 통과함을 확인한다 — 정책 자체는 옳고, 직접 경로의 호출만 어긋나 있다는 것을 이 test가 오히려 증명한다.

39. P2 — 서명된 grant의 endpoint 검증이 upload 경로에만 있다

DirectTransferPolicy의 host allowlist는 이 sub-scope의 유일한 endpoint 방어다. 그런데 실제 provider가 서명해 돌려준 URI를 그 allowlist에 대조하는 호출은 한 곳뿐이다.

// createUploadGrant
DirectGrantProvider.DirectGrantMaterial material = provider.signUpload(current.session());
policy.validateSignedGrant(material.requestUri(), current.session().expiresAt());   // ← 검증

// createDownloadGrant
DirectGrantProvider.DirectGrantMaterial material = provider.signDownload(prepared.session(), published);
// ← 대응하는 validateSignedGrant 없음

// createPartGrant (DirectMultipartCoordinator)
DirectGrantProvider.DirectGrantMaterial material = provider.signPart(session, grant.record());
// ← 대응하는 validateSignedGrant 없음. 이 coordinator는 DirectTransferPolicy를 아예 갖고 있지 않다

planGrant(...)validateEndpoint를 부르지만, 두 호출자 모두 policy.planningEndpoint()를 넘긴다. 그 메서드는 "https://" + allowedHosts 중 사전순 첫 host를 조립한 값이므로 정의상 항상 통과한다. 결국 allowlist가 실제 URI에 대해 힘을 갖는 지점은 upload 경로 한 곳뿐이고, 브라우저에 그대로 건네지는 download bearer와 multipart part bearer는 host·scheme·userinfo 검사를 통과하지 않는다.

덧붙여 validateSignedGrant(URI, Instant expectedExpiry)expectedExpirynull 검사만 하고 쓰지 않는다. 서명 재료(DirectGrantMaterial.expiresAt())와 세션이 선언한 만료가 어긋나도 걸리지 않으며, 호출자는 세션 쪽 만료를 응답에 실어 보낸다. 즉 "서명된 grant를 검증한다"는 이름이 실제로는 host 검사 하나다.

판정: P2. 현재 노출은 없다(§40: 미배선). 그러나 이 세 경로는 모두 같은 종류의 값 — 브라우저에 넘길 bearer URI — 를 다루는 형제이고, 방어가 하나에만 있다. 수정은 validateSignedGrant를 세 경로 모두에서 호출하고(그러려면 DirectMultipartCoordinator도 policy를 받아야 한다), expectedExpiry를 실제로 material.expiresAt()과 대조하는 것이다.

40. Confirmed — 직접 전송 subsystem은 미배선이고, README가 그 사실을 정확히 적는다

DirectTransferCoordinator·DirectMultipartCoordinator·DirectTransferPolicy를 이름으로 부르는 곳은 direct/** 패키지 밖에 하나도 없다(154-... §8.1b, 매치 0). DirectObjectUploadPort·DirectObjectDownloadGrantPort·DirectMultipartUploadPort 세 application port는 production 구현이 등록되지 않는다.

README가 이것을 두 문장으로 선언한다 — "Direct transfer, multipart, quarantine, retention, and production reconciliation cards remain R0."와 "no direct-grant port is registered." 이 leaf에서 반복해 확인한 정직함이고, 미배선 자체는 결함이 아니다.

41. P2 — 그러나 R0 경계가 문서에만 있고 compile 경로에서 닫히지 않는다

미배선이 선언돼 있는데도, 설정은 그 capability를 계속 받아들이고 런타임은 자격증명을 쥔 presigner를 실제로 만든다.

  1. ObjectCapabilityRequirement.DIRECT_UPLOAD / DIRECT_MULTIPART는 destination 요구사항으로 선언 가능하고, ObjectStorageBindingCompiler:206-207이 그것을 provider capability로 번역한다.
  2. S3ProviderBinding.compileProfilesMinIO에 대해서만 이 두 capability 주장을 거부한다("the exact MinIO release cannot claim native conditional managed mutation support"). AWS profile에는 대응하는 거부가 없다.
  3. 그러면 S3ObjectStorageProviderContribution:112-150directUploadEnabled || directMultipartEnabled일 때 presignerFactory.apply(clientPolicy)S3Presigner를 할당하고 S3DirectTransferProvider / S3DirectMultipartProvider를 만들어 SelectedObjectStorageProviderFactory에 넣는다.
  4. 그리고 그 둘을 factory에서 꺼내 가는 코드가 없다(154-... §8.1c, 매치 0).

즉 AWS binding에서 direct-upload를 요구하는 배포는 — startup을 통과하고, presigner를 할당하고, 두 provider를 조립하고, 직접 전송 port는 여전히 하나도 얻지 못한다. README가 말한 R0는 사실이지만, 그 사실을 강제하는 것은 문서뿐이다.

이 leaf 안에 정반대의 사례가 있어서 대비가 분명하다 — filesystem-local-devprod/production 프로파일에서 compile 시점에 거부되고, MinIO의 capability 과대 주장도 compile 시점에 거부된다. 같은 파일이 같은 종류의 "이 조합은 자격이 없다"를 AWS + DIRECT_* 에 대해서만 하지 않는다.

판정: P2. 데이터 위험은 없다 — 없는 port는 호출될 수 없다. 위험은 (a) 운영자가 켰다고 믿는 기능이 없다는 것과 (b) 아무도 쓰지 않는 서명 자격증명 핸들이 프로세스 수명 동안 살아 있다는 것이다. 수정은 셋 중 하나다: coordinator를 조건부로 조립하거나, R0인 동안 DIRECT_UPLOAD/DIRECT_MULTIPART 요구를 compile 단계에서 provider 종류와 무관하게 거부하거나, capability가 켜져도 presigner를 만들지 않도록 조립을 뒤로 미루거나.

42. P3/기록 — 선언만 되고 강제되지 않는 정책 항목

  • DirectTransferPolicy.maximumOutstandingGenerations. 생성자가 1..16 범위를 검사하지만 읽는 곳이 없다(154-... §8.4, 매치 2 = 선언과 검증뿐). DirectTransferSessionRecord.grantGeneration도 항상 1이다 — planGrantnew DirectGrantGeneration(1, ...)로 고정하고 generation을 올리는 경로가 없다. "outstanding generation을 몇 개까지 허용한다"는 정책이 표현돼 있으나 generation 자체가 재발급되지 않으므로 지금은 의미를 갖지 않는다.
  • DirectTransferCorsPolicy. "infrastructure must apply before direct admission"이라고 적혀 있으나 production 소비자가 없고, 이 값을 어디에 어떻게 반영해야 하는지 알려 주는 배선도 없다. 유일한 참조는 DirectTransferCorsContractTest다. 브라우저 계약을 코드로 고정해 둔 것 자체는 유용하나, 적용 주체가 코드 밖(인프라)이라는 사실은 README에 없다.

43. Negative-space probes — sub-scope 05

  • 8.1 reachability: coordinator·policy 모두 패키지 밖 참조 0(§40). README가 그 상태를 선언(§40). 그러나 capability는 설정에서 살아 있고 presigner는 실제로 할당된다(§41).
  • 8.2 조건부 형제 ①: requirePartSize의 세 호출 지점 중 하나만 finalPart를 하드코딩(§38).
  • 8.2b 조건부 형제 ②: validateSignedGrant가 upload 경로에만 있고 download·part 경로에는 없다(§39).
  • 8.2c 조건부 형제 ③: S3ProviderBinding이 DIRECT_* 주장을 MinIO에서만 거부(§41).
  • 8.3 중복 mechanism: 두 coordinator가 각자 독립된 process-local ConcurrentHashMap bearer 캐시를 갖는다. 둘 다 경계가 없고(무한 증가 가능) 만료된 항목을 청소하지 않으며, 제거는 성공적인 완료/확인 경로에서만 일어난다. 발급 후 완료되지 않은 세션의 재료는 프로세스 수명 동안 남는다 — 미배선이므로 지금 노출은 없고, 두 곳이 같은 방식으로 같은 성질을 갖는다는 점에서 우연이 아니라 공통 설계다. P3/기록.
  • 8.4 문서/선언 drift: 강제되지 않는 정책 항목 2종(§42).

44. Sub-scope 05 findings backlog

우선순위 finding reachability
P2 DirectMultipartCoordinator:163requirePartSize(..., false)를 하드코딩하고 PartUploadGrantRequest에 마지막 part 표시가 없어, 5 MiB 미만 part에 grant를 발급할 수 없다 — 총 5 MiB 미만 객체는 직접 multipart 불가 현재 미배선; 배선 시 모든 통상적 클라이언트 분할
P2 서명된 bearer URI의 host allowlist 검증이 upload 경로에만 있고 download·multipart part 경로에는 없다. validateSignedGrantexpectedExpiry는 검사되지 않는다 현재 미배선; 배선 시 브라우저로 나가는 두 bearer
P2 AWS binding에서 DIRECT_UPLOAD/DIRECT_MULTIPART 요구가 compile을 통과해 presigner를 할당하지만, 그것을 쓰는 port가 없다. MinIO에는 같은 주장을 막는 검사가 있다 AWS provider + direct capability 요구 배포
P3/기록 maximumOutstandingGenerations가 검증만 되고 읽히지 않으며 grant generation은 항상 1이다 정책/조립
P3/기록 DirectTransferCorsPolicy는 production 소비자 0이고, 적용 주체가 인프라라는 사실이 README에 없다 문서
P3/기록 두 coordinator의 process-local bearer 캐시가 무경계·무만료이며, 미완료 세션의 재료는 프로세스 수명 동안 남는다 배선 시

45. Sub-scope 05 완료 조건

  • denominator 25 / 25 FULL_READ (154-... OWNED FILES)
  • §8.1~§8.4 네 종 probe 수행, 조건부 형제 비교는 3건 독립 수행
  • 세 finding 모두 정적으로 결정 가능(호출 인자 상수 · 호출 부재 · 조립 경로)하여 실행 probe 불필요
  • 소스 미변경

46. Sub-scope 06 범위와 denominator

내부 상태: COMPLETE — 43 / 43 FULL_READ 범위: filesystem/** 6 + maintenance/** 8 + readiness/** 8 + provider/** 4 + 루트 4 (main 30, 2,420 LOC) + test 12 + resource 1 역할: local-dev provider, legacy 채택(adoption) 경로, readiness 증거 타입, provider-neutral 계약, 그리고 deprecated 루트 어댑터

manifest와 probe: evidence/raw/155-objectstorage-platform-readiness-probes.txt.

47. §6의 forward reference 해소 — readiness 레지스트리는 실재하고 test가 강제한다

sub-scope 01에서 build.gradle:46docs/registries/object-storage-readiness.yamltest 태스크의 입력으로 선언하는데 leaf 소스에 그 파일명을 부르는 곳이 없다는 점을 미해결로 남겼다. 해소한다.

파일은 저장소 루트에 실재한다(docs/registries/object-storage-readiness.yaml). build.gradle은 파일명을 시스템 프로퍼티 objectstorage.readiness.registry로 전달하고, leaf test ObjectStorageReadinessRegistryTest:131이 그 프로퍼티를 읽어 YAML을 파싱한다. 그래서 소스 grep에 파일명이 잡히지 않았다.

그 test가 하는 일이 이 sub-scope에서 가장 강한 governance 장치다.

  • 레지스트리 키가 schema_versionclaims 둘뿐임을 확인하고,
  • claim의 card id 집합이 ObjectStorageCapabilityCard.cardIds()(고정된 9종)와 정확히 일치함을 확인하고,
  • 각 claim에 validate(knownProviders, availableTasks)를 돌려 provider·필수 태스크·한계 문구·R2/R3 만료를 검사하고,
  • R1인 카드가 정확히 두 개(managed-upload-single, managed-download)임을 확인하고,
  • direct 3종 · quarantine · retention · reconciliation 여섯 카드가 모두 R0임을 확인한다.

README가 산문으로 적은 "Direct transfer, multipart, quarantine, retention, and production reconciliation cards remain R0"가 여기서 기계 검사가 된다. YAML 파일 자신도 헤더 주석에 소유 test 두 개(ContractRegistrySchemaGovernanceTest = 저장소 소유, ObjectStorageReadinessRegistryTest = 의미 소유)를 적어 둔다.

ObjectStorageCapabilityEvidence.validate의 개별 규칙도 좁다 — filesystem-local-dev는 R2/R3를 주장할 수 없고, requiredTasks는 비어 있을 수 없으며 전부 실재하는 태스크여야 하고, limitations도 비어 있을 수 없다. "한계를 적지 않은 readiness 주장"이 표현 불가능하다.

48. §41 보강 — 레지스트리는 문서 주장을 얼어붙히지만 런타임 설정 경로는 덮지 않는다

§41에서 "R0 경계가 문서에만 있다"고 적었다. §47을 반영해 정확히 다시 말한다.

R0 경계는 문서 주장에 대해서는 기계 검사된다(§47). 그러나 그 검사의 대상은 docs/registries/object-storage-readiness.yaml이고, KNOWN_PROVIDERSfilesystem-local-dev 하나다. 운영자가 app.object-storage 설정에 AWS provider용 qualification profile을 쓰면서 DIRECT_UPLOAD capability를 주장하는 경로는 이 레지스트리를 거치지 않는다. S3ProviderBinding.compileProfiles가 그 주장을 MinIO에 대해서만 거부하므로, AWS + DIRECT_* 조합은 여전히 compile을 통과하고 presigner를 할당한다(§41).

따라서 §41의 판정은 유지되고 오히려 선명해진다 — 이 저장소에는 "이 카드는 R0"를 강제하는 장치가 이미 있는데, 런타임 설정 경로가 그 장치의 사정권 밖에 있다.

49. P2 — APPLY를 켜는 설정은 있고, 승인을 검증하는 bean은 없다

legacy 채택은 이 leaf에서 가장 권한이 센 동작이다. 원시 locator로 legacy 네임스페이스를 읽어 관리 네임스페이스에 발행한다. 그래서 설계가 detached 2인 승인을 요구한다 — Ed25519LegacyAdoptionApprovalVerifier가 서로 다른 두 승인자의 Ed25519 서명을 검증하고, 문서의 destination/epoch/operationId/manifest·네임스페이스 다이제스트가 요청과 일치하는지, 유효창이 최대 7일 안인지까지 본다. 구현은 촘촘하다 — LegacyAdoptionApprovalCodec.decode는 길이 프레이밍 이진 코덱이고 decode 후 재인코딩해 원본 바이트와 같아야만 통과한다(비정규 인코딩 거부).

그런데 조립이 비대칭이다.

켜는 방법 bean
APPLY 실행 경로 app.object-storage.legacy-adoption.enabled=true, mode=APPLY ObjectStorageLegacyMigrationConfig:30LegacyObjectAdoptionPort를 만든다
승인 검증 없음 없음Ed25519LegacyAdoptionApprovalVerifiermain에서 생성하는 코드가 저장소 전체에 0(155-... §8.1)

LegacyObjectAdoptionSettingsenabled·mode·reportPath·reviewedManifestPath·reviewedManifestSha256·batchSize·operationTimeout을 노출하고, APPLY일 때 reviewed manifest 경로와 sha256을 요구한다. 그러나 신뢰 승인자 키, 키 id, 승인 유효기간에 해당하는 설정 항목이 하나도 없다. 검증기를 켤 방법이 설정에 없다.

adapter 쪽 LegacyObjectAdoptionService.requireApprovalrequest.approval() 객체의 필드 동등성만 검사한다 — operationKey · manifest · 네임스페이스 다이제스트 · destination · 유효창. 서명은 보지 않는다. 그리고 LegacyObjectAdoptionApproval은 application-core의 공개 생성자를 가진 값 타입이다(검증기 자신이 new로 만든다).

의도된 조립은 sample-portfolio가 보여 준다 — AdoptLegacyPosterImageUseCase.authorizeApply호출자가 준 approval을 버리고 approvals.verify(document, request)의 결과로 요청을 다시 만든다. 즉 "application에서 검증하고 adapter에서 적용한다"가 설계다. 그 use case 역시 어디에도 배선돼 있지 않다.

판정: P2. 지금 우회가 열려 있는 것은 아니다 — 검증기 bean이 없으면 AdoptLegacyPosterImageUseCase를 쓰는 fork의 컨텍스트는 시작에 실패하고, 그것은 조용하지 않은 실패다. 위험은 다른 쪽이다: 설정 한 줄로 켜지는 절반과 손으로 배선해야 하는 절반이 있고, 켜지는 쪽이 권한이 센 쪽이다. enabled=true, mode=APPLY를 켠 fork가 use case를 쓰지 않고 port를 직접 부르면 adapter의 검사는 필드 동등성뿐이다. 수정은 ObjectStorageLegacyMigrationConfig가 승인자 키를 설정에서 읽어 Ed25519LegacyAdoptionApprovalVerifier bean도 함께 만들되, APPLY 모드에서 그 bean이 없으면 startup을 거부하는 것이다.

LegacyObjectAdoptionServiceTest에 test가 하나뿐이고 그것이 reportOnlyInspectsExactEvidenceAndPerformsNoMutation이라는 점도 같은 방향을 가리킨다 — APPLY 경로에는 서비스 수준 test가 없다. 검증기 자체는 LegacyAdoptionApprovalVerifierTest가 두 케이스(verifiesCanonicalExactBindingWithTwoDistinctTrustedApprovers, rejectsDuplicateApproverAndAnyBindingTamper)로 덮는다.

50. P3 — nonce replay 경계가 결과를 읽고 버린다

LegacyAdoptionApprovalReplayStore.ClaimResult claim = replayStore.claim(replay);
var published = publications.publish(request.publicationRequest(), inspected.producer());
if (claim != ClaimResult.TERMINAL_REPLAY) { replayStore.markTerminal(...); }

ClaimResultCLAIMED / EXACT_REPLAY / TERMINAL_REPLAY 셋인데, 어느 값이든 발행은 그대로 진행된다. claim 결과가 바꾸는 것은 terminal 기록을 쓸지 여부뿐이다. 인터페이스 javadoc은 자신을 "Durable compare-and-set nonce replay boundary"라고 부르지만, 경계로서 무엇도 막지 않는다.

실제 피해는 제한적이다 — 발행이 operation-key 기반 멱등이므로 이미 소진된 nonce로 다시 들어와도 결과는 REPLAYED이고 두 번째 객체가 생기지 않는다. 그래서 P3다. 그러나 (a) 이름이 약속하는 것과 다르고, (b) markTerminal에 넘기는 expectedRevision이 항상 replay.revision() = 0이라 CAS 인자로서도 고정값이며, (c) 승인 문서의 nonce가 "한 번만 쓰인다"는 성질은 이 코드로는 보장되지 않는다. 수정은 TERMINAL_REPLAY에서 발행 전에 거부하는 것이다.

51. Confirmed — local-dev provider의 경로 방어와 publication

LocalObjectPathGuard는 이 저장소에서 반복해 본 강한 형태다 — root 정규화 + startsWith 봉쇄 + root 자신 거부에 더해, 부모 경로를 root부터 한 세그먼트씩 내려가며 심링크와 비디렉터리를 거부하고(createParentsWithoutLinks / rejectExistingLinks), 대상 자신도 심링크면 거부한다. control key는 control/v1/ 접두사 + [a-z0-9._/-]+ + //·/./·/../ 금지 + 세그먼트별 재검사다. 그리고 control 레코드는 물리 파일명에 .record를 붙인다 — 객체 저장소가 허용하는 referencereference/lifecycle 쌍이 파일시스템에서 파일/디렉터리 충돌을 일으키지 않도록. 논리 키는 그대로 유지된다.

발행은 배타적 하드링크다. LocalDevObjectDataStore.create가 임시 파일에 쓰고 channel.force(true)Files.createLink(target, temporary)를 하며, FileAlreadyExistsExceptionCONFLICT로, UnsupportedOperationException을 "local filesystem cannot prove immutable create"로 번역한다 — 하드링크를 지원하지 않는 파일시스템에서 조용히 약한 방식으로 내려가지 않는다. 앞선 Files.exists(NOFOLLOW) 검사는 빠른 경로일 뿐이고 배타성은 createLink가 준다. POSIX면 소유자 읽기 전용 권한을 씌운다.

test가 이것들을 이름으로 잡는다 — traversalAbsoluteUnicodePercentAndSymlinkEscapesAreRejected, exclusiveCreateRaceHasOneWinner, injectedDiskFailureLeavesNoFinalOrTemporaryData, restartInspectsCommittedDataWithoutReplayingProducer, corruptControlRecordRemainsPresentAndNeverAppearsAbsent, createsRestrictivePermissionsWherePosixIsSupported. 마지막에서 두 번째가 특히 이 leaf의 규칙이다 — 손상된 control 레코드는 부재로 보이지 않는다.

LocalDevObjectControlStore도 자신의 한계를 클래스 javadoc에 적는다 — "Single-process local create/CAS store; it deliberately does not claim multi-node linearizability." 그리고 descriptor의 capability 표가 MULTI_NODE_LINEARIZABLE_CASPOWER_LOSS_DURABILITYUNSUPPORTED로 명시한다. ObjectStorageProviderContract:184unsupportedOptionalCapabilitiesAreDeclaredRatherThanSkipped가 그 선언을 강제한다 — 지원하지 않는 것을 test에서 건너뛰는 대신 선언하게 만든다.

52. P3/기록 — 같은 capability 표가 두 벌 있다

filesystem-local-dev의 capability 표가 두 곳에 독립적으로 하드코딩돼 있다.

  • FilesystemLocalDevProviderContribution.capabilitySupport()describe(settings)가 쓰는 것. sub-scope 01에서 확인했듯 이것이 compile 시점 권위다.
  • LocalDevObjectStorageProvider.capabilitySupport() — 런타임 descriptor()가 쓰는 것.

현재 둘은 같다(각각 Support.SUPPORTED 6개, 같은 여섯 capability). 그러나 공유하는 상수도, 한쪽이 다른 쪽을 부르는 구조도 없다. 한쪽만 고치면 compile이 허용한 capability와 런타임이 주장하는 capability가 갈라지고, 그 불일치를 잡는 test는 없다. P3/기록. 수정은 표를 한 곳에 두고 양쪽이 그것을 부르는 것이다.

53. P3/기록 — deprecated 루트 어댑터에는 형제에게 있는 방어가 없다

루트의 네 파일(ObjectStorageSettings · ObjectStorageConfig · FilesystemObjectStorageAdapter · S3ObjectStorageAdapter)은 README가 "deprecated compatibility only... never back the new semantic ports"로 선언한 경로이고, LegacyObjectStorageActivationGuard가 명시적 opt-in을 요구하며 canonical 네임스페이스와의 혼용을 예외로 막는다. 그 선까지는 규율이 있다.

그 아래에서는 canonical 경로에 있는 방어가 하나씩 없다.

canonical legacy 루트
autoCreateBucket S3ProviderBinding:268autoCreateBucket || publicAcl거부 ObjectStorageSettings:46 기본값 true, startup에서 실제로 버킷 생성
평문 엔드포인트 AWS 도메인 + http 거부 기본값 http://localhost:9000
심링크 LocalObjectPathGuard가 root·부모·대상 전부 거부 FilesystemObjectStorageAdapter.resolvenormalize() + startsWith만 — 심링크 검사 0
production 프로파일 filesystem-local-devprod/production에서 거부 LegacyObjectStorageActivationGuard에 프로파일 검사 없음

LegacyObjectStorageConfigTest.autoCreateBucketTrueProvisionsTheBucketWhileTheS3BeanIsCreated가 그 기본값을 의도된 동작으로 고정하고 있으므로 우발적 잔재는 아니다. 그래서 P3/기록이다 — deprecated 경로가 자신의 과거 의미를 보존하는 것은 정당하고, 위험한 것은 그 경로가 production 프로파일에서 아무 제지 없이 켜진다는 점 하나다. FilesystemObjectStorageAdapter의 심링크 미검사는 baseDir 안에 심링크를 심을 수 있는 로컬 접근을 전제하므로 노출이 좁다.

54. Negative-space probes — sub-scope 06

  • 8.1 reachability: maintenance/**는 설정 flag로 배선된다(§49). readiness/**의 세 타입은 leaf test가 소비한다(§47) — 앞선 grep이 test 패키지까지 제외해 0으로 보였던 것을 바로잡았다.
  • 8.1b forward reference 해소: build.gradle → 시스템 프로퍼티 → leaf test → 루트 YAML(§47).
  • 8.2 조건부 형제 ①: 켜지는 절반과 손배선 절반의 비대칭(§49).
  • 8.2b 조건부 형제 ②: legacy 루트 vs canonical의 네 가지 방어 차이(§53).
  • 8.3 중복 mechanism: capability 표 두 벌(§52).
  • 8.4 선언 대비 강제: nonce replay 경계가 결과를 쓰지 않음(§50). readiness 주장의 한계 문구 강제(§47).

55. Sub-scope 06 findings backlog

우선순위 finding reachability
P2 legacy-adoption.enabled=true, mode=APPLY는 설정으로 켜지는데 Ed25519LegacyAdoptionApprovalVerifier bean은 저장소 어디에도 없고 승인자 키 설정 항목도 없다. adapter의 승인 검사는 필드 동등성뿐 APPLY를 켜고 port를 직접 부르는 fork
P3 LegacyAdoptionApprovalReplayStore.claim 결과가 발행을 막지 않는다 — TERMINAL_REPLAY도 그대로 발행 APPLY 경로
P3/기록 filesystem-local-dev capability 표가 contribution과 provider에 두 벌로 하드코딩 표가 갈라질 때
P3/기록 deprecated 루트 경로에 production 프로파일 검사가 없고 autoCreateBucket 기본값이 true, 평문 엔드포인트가 기본값, 심링크 검사 없음 legacy opt-in 배포

56. Sub-scope 06 완료 조건

  • denominator 43 / 43 FULL_READ (155-... OWNED FILES: main 30 / test+resource 13)
  • §8.1~§8.4 probe 수행, 조건부 형제 비교 2건, 중복 mechanism 1건
  • §6의 readiness 레지스트리 forward reference 해소(§47) 및 그에 따른 §41 보강(§48)
  • 소스 미변경

57. Sub-scope 07 범위와 denominator

내부 상태: COMPLETE — 6 / 6 FULL_READ 범위: 전용 qualification source set 3종 6파일 (546 LOC) 역할: 실제 provider를 상대로 "무엇이 되고 무엇이 안 되는지"를 증거로 고정하는 lane

manifest와 probe: evidence/raw/156-objectstorage-qualification-lanes-probes.txt.

source set 파일 성격
objectStorageMinioContractTest MinioManagedObjectContractTest 291 · MinioDirectTransferContractTest 25 전자는 실제 MinIO 컨테이너 기동
objectStorageMinioFaultTest MinioManagedObjectFaultTest 147 · MinioDirectTransferFaultTest 32 전자는 MinIO + Toxiproxy
objectStorageAwsQualificationTest AwsS3ManagedCommonSubsetQualificationTest 27 · AwsS3DirectTransferQualificationTest 24 환경변수 권한 검사만

세 lane 모두 registerStrictQualificationTest로 등록되고 requiredClasses로 클래스 이름이 고정된다 — 클래스를 지우거나 이름을 바꾸면 lane 등록이 실패한다. 설명 문구도 "non-skipping"을 명시한다. 즉 "Docker 없으면 조용히 skip"이 이 lane들에는 없다.

58. Confirmed — MinIO의 조건부 create가 작동하지 않는다는 것을 실측으로 증명한다

MinioManagedObjectContractTest가 digest로 고정된 MinIO 이미지를 띄우고 다음을 순서대로 확인한다.

  1. PutObject + If-None-Match: * + SHA-256 체크섬 + AES256 → 성공.
  2. 같은 키에 다시 PutObject + If-None-Match: *또 성공하고 내용이 덮인다. 변수 이름이 결론이다: overwrittenDespiteCreateOnlyCondition.
  3. HeadObject로 길이·ca-logical-sha256 메타데이터·SSE가 보존됨을 확인, GetObject + If-Match + Range로 부분 읽기가 정확함을 확인.
  4. control 키에 대해서도 같은 일이 일어남을 확인(overwrittenControlCreate).
  5. 그러나 stale If-Match는 HTTP 412로 정확히 거부됨을 확인.
  6. 두 번째 test에서 low-level multipart가 동작하되 CompleteMultipartUpload + If-None-Match: *기존 객체를 덮는다는 것을 확인.

이 여섯 줄이 sub-scope 04에서 본 S3ProviderBinding의 MinIO 거부 규칙 — "the exact MinIO release cannot claim native conditional managed mutation support" — 의 근거다. 즉 코드의 거부가 의견이 아니라 이 lane의 실측 결과다.

결과는 src/test/resources/object-storage/minio-provider-evidence.json에 고정돼 있고, 그 한계 문구가 그대로 사람이 읽을 문장이다 — "PutObject If-None-Match was accepted and overwrote an existing object", "CompleteMultipartUpload accepted If-None-Match despite a pre-existing target and overwrote it", 그리고 범위를 좁히는 세 줄("local single-node container evidence only", "not AWS evidence", "not production TLS or deployment-topology evidence").

MinioManagedObjectFaultTest는 Toxiproxy로 양방향 대역폭을 0으로 만들고, 실패가 5초 안에 유계로 발생하는지와 복구 후 같은 객체가 그대로인지를 본다 — connectionCutProducesABoundedFailureAndRecoveryWithoutMutationReplay. 컨테이너 두 개가 모두 try-with-resources 안에 있고 @SuppressWarnings("resource")에 그 이유가 주석으로 적혀 있다.

MinioDirectTransfer* 두 파일이 provider를 부르지 않는 것도 근거가 있다 — javadoc이 "No bearer or multipart mutation is attempted because qualification proved that create-only PUT and create-only multipart completion are ignored by this provider identity." 즉 이미 증명된 결과를 근거로 mutation 행렬에 들어가지 않겠다는 선언이고, 두 파일은 그 결정을 evidence 파일에 대한 assertion으로 얼려 둔다.

59. P3/기록 — AWS lane은 환경변수만 검사하고 통과한다

AwsS3ManagedCommonSubsetQualificationTestAwsS3DirectTransferQualificationTest의 본문은 전부 다음 형태다.

Map<String, String> environment = System.getenv();
assertThat(environment.get("OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED")).isEqualTo("true");
assertThat(environment.get("OBJECT_STORAGE_AWS_BUCKET")).isNotBlank();
assertThat(environment.get("OBJECT_STORAGE_AWS_REGION")).isNotBlank();
assertThat(environment.get("OBJECT_STORAGE_AWS_EXPECTED_OWNER")).matches("[0-9]{12}");

AWS를 호출하는 코드는 한 줄도 없다. 권한이 없으면 실패한다는 점에서 fail-closed이지만, 권한이 있어도 아무것도 증명하지 않는다. lane 이름은 objectStorageAwsQualificationTest이고 설명은 "Runs only with explicit protected AWS sandbox authority and exact inputs"이므로, CI에서 이 lane이 초록으로 통과한 것을 AWS가 자격 검증되었다는 증거로 읽을 여지가 있다.

지금 실제 위험은 낮다 — readiness 레지스트리의 KNOWN_PROVIDERSfilesystem-local-dev 하나뿐이라 AWS 주장 row 자체가 없고(§47), README도 "It does not prove … production credentials/TLS/IAM/encryption, S3 response-loss behavior, or R2 readiness"라고 적는다. 그래서 P3/기록이다. 다만 CapabilityEvidenceSource.CI_QUALIFICATION이라는 값이 존재하고 §41에서 본 대로 AWS profile은 capability를 주장할 수 있으므로, fork가 이 초록 lane을 근거로 삼는 경로가 구조적으로 열려 있다. 수정은 lane 본문을 실제 sandbox 호출로 채우거나, 채우기 전까지 클래스/lane 이름에 "authority-gate"임이 드러나게 하는 것이다.

60. P3/기록 — provider 신원 문자열이 세 곳에 독립적으로 적혀 있다

정확한 MinIO 신원이 세 곳에 문자열로 존재한다.

위치
S3ProviderType.MINIO_COMMUNITY_2024_01_16:8 s3-compatible-minio-community-release-2024-01-16t16-07-38z
S3ProviderVersion:7 release-2024-01-16t16-07-38z-sdk-2.30.0
minio-provider-evidence.json:3-4 위 두 값과 정확히 동일

컨테이너 이미지 digest도 세 곳(contract test, fault test, evidence JSON)에 같은 값으로 적혀 있고 현재 모두 일치한다. 그러나 이 일치를 검사하는 test는 없다 — evidence JSON에 대한 assertion은 status와 profile 이름에 대한 부분 문자열 확인뿐이다(§58). production 바인딩이 인정하는 신원과 자격 검증이 실제로 돌아간 신원이 갈라져도 아무도 알려주지 않는다. P3/기록. 수정은 evidence JSON을 파싱해 S3ProviderType/S3ProviderVersion의 canonical 값 및 lane의 이미지 digest와 대조하는 assertion 하나를 추가하는 것이다.

61. Negative-space probes — sub-scope 07

  • 8.1 lane 등록: 세 lane 모두 strict·non-skipping·requiredClasses 고정(§57).
  • 8.2 조건부 형제: 여섯 파일 중 provider를 실제로 부르는 것은 (MinioManagedObject*). 나머지 넷 중 둘은 근거 있는 freeze(§58), 둘은 근거 없는 통과(§59).
  • 8.3 중복 신원: 신원 문자열·이미지 digest 3중 기재, 교차 검사 없음(§60).
  • 8.4 증거의 자기 한정: evidence JSON의 limitations 7줄이 범위를 스스로 좁힌다(§58).

62. Sub-scope 07 완료 조건

  • denominator 6 / 6 FULL_READ (156-... OWNED FILES)
  • §8.1~§8.4 probe 수행
  • lane 실행은 Docker와 보호된 AWS sandbox 권한이 필요하므로 이 분석에서 실행하지 않음 — 대신 lane이 무엇을 주장하고 그 주장이 어디에 소비되는지를 정적으로 추적
  • 소스 미변경

63. 모듈 ledger 정합

# 범위 main test 기타 FULL_READ probe
1 governance + config/** 19 5 4 28 28 149, 150
2 control/** 24 1 25 25 151
3 kernel/** + codec/** 30 9 39 39 152
4 s3/** 26 14 40 40 153
5 direct/** + multipart/** 18 7 25 25 154
6 filesystem/**+maintenance/**+readiness/**+provider/**+루트 30 12 1 43 43 155
7 qualification source set 3종 6 6 6 156
TOTAL 147 48 11 206 206 7 / 7

coverage ledger: FULL_READ 206 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / 미분류 0.

64. 모듈 findings

# 우선순위 finding 위치 reachability
1 P2 직접 multipart의 마지막 part에 grant를 발급할 수 없다 — requirePartSize(..., false) 하드코딩 + 요청 타입에 마지막 part 표시 없음 §38 미배선; 배선 시 통상적 클라이언트 분할 전부
2 P2 서명된 bearer URI의 allowlist 검증이 upload 경로에만 있고 download·multipart part 경로에는 없다. expectedExpiry는 검사되지 않는다 §39 미배선; 배선 시 브라우저로 나가는 두 bearer
3 P2 AWS binding에서 DIRECT_UPLOAD/DIRECT_MULTIPART 요구가 compile을 통과해 presigner를 할당하지만 그것을 쓰는 port가 없다. MinIO에는 같은 주장을 막는 검사가 있다 §41, §48 AWS provider + direct capability 요구 배포
4 P2 legacy-adoption.enabled=true, mode=APPLY는 설정으로 켜지는데 Ed25519 승인 검증기 bean과 승인자 키 설정이 없다 — adapter의 승인 검사는 필드 동등성뿐 §49 APPLY를 켜고 port를 직접 부르는 fork
5 P3 filesystem-local-dev production 거부가 prod/production 두 리터럴에만 걸려 있다 §5 프로파일 이름을 바꾼 fork
6 P3 nonce replay 경계가 ClaimResult를 읽고 버린다 — TERMINAL_REPLAY도 그대로 발행 §50 APPLY 경로
7 P3/기록 markEffectSent·markResponseLostupdatedAt을 전진시키지 않는다 §22 응답 유실 조사
8 P3/기록 maximumOutstandingGenerations가 검증만 되고 읽히지 않으며 grant generation은 항상 1 §42 정책/조립
9 P3/기록 DirectTransferCorsPolicy의 production 소비자 0, 적용 주체가 인프라라는 사실이 README에 없음 §42 문서
10 P3/기록 두 coordinator의 process-local bearer 캐시가 무경계·무만료 §43 배선 시
11 P3/기록 filesystem-local-dev capability 표가 두 벌로 하드코딩 §52 표가 갈라질 때
12 P3/기록 deprecated 루트 경로에 production 프로파일 검사 없음, autoCreateBucket 기본 true, 평문 엔드포인트 기본, 심링크 검사 없음 §53 legacy opt-in 배포
13 P3/기록 AWS qualification lane이 환경변수만 검사하고 통과한다 §59 초록 lane을 증거로 삼는 fork
14 P3/기록 provider 신원 문자열·이미지 digest가 세 곳에 독립 기재, 교차 검사 없음 §60 신원이 갈라질 때

결함 아님으로 판정한 후보 3건RoutingObjectReadAdapter의 무방비 split(§7: ObjectReference 생성자 검증이 막는다), control 레코드의 관용 UTF-8 디코딩(§16: printable ASCII 검사가 닫는다), sub-scope 02·04의 zero-finding 결과 자체.

이월 해소 1건 — sub-scope 01의 readiness registry forward reference를 §47에서 확정(파일은 저장소 루트에 실재하고 leaf test가 시스템 프로퍼티로 읽어 9장 카드와 R0/R1 수준을 강제한다).

65. 이 모듈에서 반복해서 나타난 패턴

  • 불확실성을 보존한다. mutation의 비권위적 실패는 INDETERMINATE로 분류되고 정확한 GET으로만 해소된다(§30). 손상된 control 레코드는 부재로 보이지 않는다(§51). 부재로 읽히는 유일한 신호는 정확한 404다.
  • 값이 아니라 값들 사이의 관계를 검증한다. 재시도 최악 예산이 부모 호출 예산 안에 드는지(§28), provider 타입마다 반대 방향의 신원 규칙(§29), 레코드 상태와 증거 완전성의 결합(§37).
  • 표현 불가능성으로 막는다. canonical key 문법, 논리 다이제스트와 provider 체크섬의 이중 대조(§31), 길이 프레이밍 이진 코덱의 재인코딩 검사(§49).
  • 자기 한정을 문서에 적는다. README의 R0 선언과 evidence JSON의 limitations 7줄(§58), "does not claim multi-node linearizability"(§51).
  • 그리고 이 모듈의 P2 넷은 전부 같은 모양이다 — 설정 표면이나 계약이 절반만 조립돼 있다. 마지막 part 표시가 요청 타입에 없고(§38), 검증이 세 경로 중 하나에만 있고(§39), capability가 compile을 통과하는데 소비자가 없고(§41), APPLY는 설정으로 켜지는데 검증기는 손배선이다(§49). 개별 구현의 품질과 조립의 대칭성 사이에 일관된 격차가 있다.

66. 모듈 완료 조건

  • denominator 206 / 206 FULL_READ, STRUCTURAL_ONLY 0, EXCLUDED 0, 미분류 0 (§63)
  • 7개 하위 범위 전부 §8.1~§8.4 네 종 negative-space probe 수행, evidence 149~156 8건 생성
  • 후보 finding 3건을 코드로 추적해 결함 아님으로 판정, 이월 1건 해소
  • 소스 미변경 — 이 분석은 어떤 애플리케이션 코드도 수정하지 않았다

67. 검증

evidence/raw/157-objectstorage-suite-verification.txt.

$ cd src && LANG=C.UTF-8 LC_ALL=C.UTF-8 ./gradlew :adapter:outbound:objectstorage:test --console=plain -q
GRADLE_EXIT=0
classes=47 tests=140 failures=0 errors=0 skipped=0

$ git status --short
changed=0

skip 0이라는 점을 기록해 둔다 — 이 leaf의 :test에는 조건부로 비활성화되는 test가 없다. Docker나 보호된 자격이 필요한 것들은 애초에 별도 source set으로 분리돼 있고(§57), :test에 섞여 들어와 조용히 건너뛰지 않는다. 그 세 lane(objectStorageMinioContractTest·objectStorageMinioFaultTest·objectStorageAwsQualificationTest)은 이 분석에서 실행하지 않았다.

작업 트리는 변경 0이다 — 이 분석 과정에서 애플리케이션 소스를 수정하거나 임시 파일을 남기지 않았다.

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 82개 (main 64 · test 10 · 기타 8).

src/adapter/outbound/objectstorage/build.gradle
src/config/architecture/modules.json                    (adapter-outbound-objectstorage 항목)

main:
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/FilesystemObjectStorageAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/CrockfordBase32.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectControlKeyCodec.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectDataKeyCodec.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectAdoptionSettings.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectStorageActivationGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompiler.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityAssembler.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceCapabilityConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderContribution.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectDirectGrantAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectReadAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/SelectedObjectStorageProviderFactory.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonReader.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ControlRecordSupport.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecord.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlStore.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectOperationRecord.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectPublicationHandoffRecord.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectStagedObjectRecord.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/UnsupportedObjectControlSchemaException.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCompletionVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinator.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinator.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferSessionRecord.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactor.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/FilesystemLocalDevProviderContribution.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectControlStore.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectDataStore.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalObjectPathGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernel.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachine.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PendingObjectEffect.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernel.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/Ed25519LegacyAdoptionApprovalVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalCodec.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalReplayStore.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionService.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartPartLedger.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartUploadPlan.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidenceSource.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityCard.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityEvidence.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridge.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridge.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ChecksumPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ClientPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStore.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalRequestMapper.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectEvidenceMapper.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBinding.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapper.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderType.java
  src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderVersion.java

test:
  src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageConfigTest.java
  src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectNamespaceCodecTest.java
  src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinatorTest.java
  src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsContractTest.java
  src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachineTest.java
  src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalVerifierTest.java
  src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionServiceTest.java
  src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessRegistryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactoryTest.java
  src/test/resources/object-storage/minio-provider-evidence.json

기타:
  docs/registries/object-storage-readiness.yaml
  src/build.gradle
  src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3DirectTransferQualificationTest.java
  src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ManagedCommonSubsetQualificationTest.java
  src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferContractTest.java
  src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectContractTest.java
  src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferFaultTest.java
  src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectFaultTest.java

해석되지 않은 인용 (11종) — 외부 타입·문서상 약칭 등:
  application.yml
  evidence/raw/149-objectstorage-module-inventory.txt
  evidence/raw/150-objectstorage-config-activation-probes.txt
  application-prod.yml
  evidence/raw/151-objectstorage-control-probes.txt
  evidence/raw/152-objectstorage-kernel-codec-probes.txt
  evidence/raw/153-objectstorage-s3-probes.txt
  evidence/raw/154-objectstorage-direct-multipart-probes.txt
  evidence/raw/155-objectstorage-platform-readiness-probes.txt
  evidence/raw/156-objectstorage-qualification-lanes-probes.txt
  evidence/raw/157-objectstorage-suite-verification.txt


A10. adapter-outbound-cache-redis

분석 중에는 10-adapter-outbound-cache-redis.md 파일이었다. 1,102줄.

10 · adapter-outbound-cache-redis

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-outbound-cache-redis
  • canonical state analysisFile: §A10 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/outbound/cache-redis · Gradle :adapter:outbound:cache-redis
  • registry allowed_dependencies: ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"]
  • registry runtime_memberships: ["app-bootstrap"]
  • coverage ledger: FULL_READ 390 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


상태: IN_PROGRESS
revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
경로: src/adapter/outbound/cache-redis · Gradle: :adapter:outbound:cache-redis

0. Denominator와 coverage ledger

tracked file 390개 — main 316 (Java 314 / 32,082 LOC + resource 2), test 70 (Java 69 / 15,244 LOC + golden 1), governance 4. 총 약 47.3k LOC.

{ "id": "adapter-outbound-cache-redis",
  "gradle_path": ":adapter:outbound:cache-redis",
  "allowed_dependencies": ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"],
  "runtime_memberships": ["app-bootstrap"] }

이 leaf는 다른 leaf와 모양이 다르다. 자체 Redis SDK를 품고 있고, build.gradle 첫 줄이 그 사실과 이유를 적는다 — 설계 문서는 SDK를 여러 Gradle 모듈로 나누지만 "This repository's fail-closed module registry outranks that layout, so the module boundaries are packages under dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them."

main 패키지 배치(Java 314):

계층 패키지 파일 LOC
공개 API sdk/api/operations 69 3,048
sdk/api/reactive 17 1,630
sdk/api/key 17 702
sdk/api/error 14 510
sdk/api/command 13 501
sdk/api · sdk/api/codec 9 754
Lettuce 구현 sdk/lettuce/operations 67 10,718
sdk/lettuce/command 14 1,825
sdk/lettuce/connection 9 1,473
sdk/lettuce/codec · observability 9 996
부가 기능 sdk/programmability 17 1,504
sdk/extensions/** 17 1,508
sdk/raw · cluster · admin 16 1,403
조립 sdk/config 12 2,215
의미 포트 어댑터 cache · idempotency · lease · ratelimit · realtime · keyspace 14 3,295

build.gradle부재를 명시적으로 선언한다 — spring-data-redis는 "routing through Spring Data would reintroduce the untyped, unguarded command surface the catalog exists to prevent. Zero imports.", micrometer-core는 "binding it to a meter registry belongs to the composition root, not here. Zero imports." 두 부재 모두 근거와 함께 적혀 있다.

topology lane(redisTopologyTest)은 이 저장소에서 본 것 중 가장 방어적으로 작성된 Gradle 태스크다 — mode allowlist(standalone·sentinel·cluster·tls), lane별 필수 프로퍼티, lane 태그가 실제 소스에 존재하는지의 사전 확인, failOnNoDiscoveredTests, 실행 0건 실패, lane별 필수 클래스 이름 목록, lane별 최소 test 수 하한, 그리고 skip 1건이라도 있으면 실패. 각 방어에 그것이 막는 과거 실패가 주석으로 붙어 있다("-Predis.topology.mode=TYPO built the tag lane-typo, matched nothing, ran zero tests and exited 0").

하위 범위 ledger
# 범위 main test 기타 상태
1 governance + sdk/config/** — 설정 · auto-configuration · startup probe · 조립 12 8 6 26 COMPLETE
2 sdk/api/operations + sdk/api 루트 + sdk/api/codec — 타입 있는 명령 표면 78 5 83 COMPLETE
3 sdk/api/key + error + command + reactive — 키·오류·명령 기술·리액티브 61 3 64 COMPLETE
4 sdk/lettuce/operations — gateway · request builder · in-memory double 67 28 95 COMPLETE
5 sdk/lettuce/command + connection + codec + observability — 정책 가드 · 연결 · 코덱 32 11 1 44 COMPLETE
6 sdk/programmability + extensions/** + raw + cluster + admin 50 4 54 COMPLETE
7 의미 포트 어댑터 6종 + live/topology test 기반 14 10 24 COMPLETE
TOTAL 314 69 7 390 7 / 7

manifest: evidence/raw/158-cache-redis-module-inventory.txt.


1. Sub-scope 01 범위와 denominator

내부 상태: COMPLETE — 26 / 26 FULL_READ 범위: governance 4 + sdk/config/** main 12 (2,215 LOC) + 전용 test 8 + main resource 2 역할: app.redis.enabled 하나로 열리는 조립 지점 — 설정 bind · cross-field 검증 · 자격증명 해석 · 클라이언트/연결/health 조립

manifest와 probe: evidence/raw/159-cache-redis-config-activation-probes.txt.

2. 조립의 순서가 클래스 하나에 고정돼 있다

RedisSdkAutoConfiguration의 javadoc이 규칙을 적는다 — "app.redis.enabled is the whole switch. While it is false this class contributes nothing, and because RedisSdkSettings is registered here rather than by the application-wide @ConfigurationPropertiesScan, 'contributes nothing' is literal: the properties are not bound, the cross-field rules are not run, no credential is resolved, and no policy resource, TLS material, client, connection or thread is created."

그 문장이 구조로 뒷받침된다. RedisSdkSettings@ConfigurationPropertiesScan 대상이 아니라 이 클래스의 @Bean + @ConfigurationProperties로만 존재한다. 그래서 Redis를 쓰지 않는 배포는 Redis 설정을 들고 다니지 않고, 켠 적 없는 잘못된 Redis 설정 때문에 벌을 받지도 않는다. test가 그 넷을 이름으로 고정한다 — absentSwitchRegistersNothing, disabledRegistersNothing, disabledIgnoresMalformedRedisConfiguration, disabledNeverAsksForASecretOrAConnection.

순서도 bind → validate → build로 고정된다. 검증이 @PostConstruct나 리스너가 아니라 bean factory 메서드 안에 있어서, 설정 오류가 "그 bean을 만들지 못했다"는 실패로 보고되고 그 아래 어떤 것도 검증되지 않은 settings를 잡을 수 없다. 그리고 redisSdkSettingsValidation이 별도 bean인 이유도 적혀 있다 — Spring은 factory 메서드가 반환한 뒤에 binder를 돌리므로 검증이 redisSdkSettings() 안에 있을 수 없다.

RedisSdkSettings.validate()가 값이 아니라 값들 사이의 관계를 본다.

  • Cluster이면 database는 0만, Sentinel이면 master-name 필수
  • TLS 클라이언트 인증서가 있으면 개인키 참조 필수("a certificate without its private key cannot be presented")
  • 다섯 timeout 프로파일 전부 양수·상한 이하, fast가 500ms 가이드를 넘으면 warning(실패 아님)
  • blocking 명령은 무한 대기 금지, 전용 연결 레인은 양수 상한
  • raw gateway와 admin plane은 각자의 자격증명 참조를 요구
  • advanced가 꺼져 있는데 advanced 정책이 설정돼 있으면 실패
  • 마지막에 authentication.validate(warnings) — 그 순서에 이유가 적혀 있다("a deployment with both a structural mistake and a missing credential should be told about the structural one first")

자격증명은 값이 아니라 참조만 설정에 들어간다(secret://<source>/<name>). RedisCredentialResolver가 startup에 해석하고, 스킴이 아니면 리터럴 비밀번호로 통과시키지 않고 거부한다. secret://<user>@<source>/<name> 형태로 ACL 계정 이름을 지정할 수 있고 없으면 default인데, javadoc이 그 기본값의 의미를 적는다 — "default — which is exactly the account a hardened deployment disables." RedisCredentials.toString()은 비밀번호를 ***로 만든다.

그리고 설정된 모든 역할이 해석된다 — application뿐 아니라 advanced·pubsub·admin·raw·sentinel까지. 이유가 적혀 있다: "A deployment that named an advanced or pub/sub account and got a client that silently authenticated as the application account has the privilege separation it configured on paper and nowhere else."

3. Confirmed — raw allowlist 기본값은 없는 리소스를 가리키고, 그것이 의도다

RedisSdkSettings.Raw.policyResource 기본값은 classpath:redis-sdk/raw-command-allowlist.yml인데, 저장소에 그 파일은 없다(159-... §8.3b, git ls-files 매치 0. 이 leaf의 main resource는 AutoConfiguration.importsredis-sdk/redis-command-policy.yml 둘뿐).

이것은 결함이 아니라 이미 잡혀 있는 함정이다. requireRawPolicyResource가 그 사실과 과거 증상을 함께 적는다 — "validate() only checks that the setting is non-blank, and the default points at … a resource this module does not ship. So enabling the raw gateway passed configuration validation and then failed at the first raw command, from inside a request, against a live connection. The allowlist is the entire authorisation model for that gateway; not being able to read it is a startup failure." test enabledRejectsAMissingRawAllowlistResourceenabledAcceptsAReadableRawAllowlistResource가 양쪽을 고정한다.

4. Confirmed — "하나의 상수, 두 독자"가 실제로 지켜진다

RedisCorrectnessRoles의 javadoc은 이 클래스가 존재하는 이유를 과거 장애로 설명한다 — redisRequired health contributor를 만들지 결정하는 조건과 readiness 그룹이 그 이름을 넣어도 되는지 결정하는 post-processor가 서로 어긋나자, Boot가 존재하지 않는 contributor를 named group에서 발견하고 모든 Redis-off 배포가 startup에서 실패했다("Included health contributor 'redisRequired' in group 'readiness' does not exist").

주장을 검증했다. app-bootstrapRedisReadinessGroupPostProcessor가 이 leaf의 상수를 실제로 import해서 읽는다:45에서 RedisCorrectnessRoles.anySelected(environment), :54에서 RedisCorrectnessRoles.REQUIRED_HEALTH_CONTRIBUTOR. 술어가 복제돼 있지 않다. 주장대로다.

두 contributor의 분리도 그 자체로 규칙이다 — redisOptional은 도달 불가일 때 DOWN이 아니라 커스텀 DEGRADED를 반환하고("a status the readiness group understands as failure would remove a healthy pod from service — shrinking capacity during the exact incident that needs it most"), redisRequired는 correctness 역할(session·idempotency·rate-limit·lease) 중 하나가 Redis를 선택했을 때만 존재하며 DOWN을 낸다. probe는 플래그가 아니라 실제 왕복이고("isOpen() on a driver connection reports what the client believes, which stays true for as long as it takes TCP to notice"), 실패 detail에는 예외 클래스 이름만 넣는다("a driver message can carry an endpoint, a username, or a key").

5. P2 — README readiness 표와 build.gradle 주석이 실제 소스와 어긋난다

README의 readiness 표(README:2329)와 그 아래 두 문단이 이 leaf의 현재 상태를 서술한다. 네 행 중 세 행이 사실과 다르다.

README가 말하는 것 실제
"Topology client / connection lifecycle — API 구현 없음" sdk/lettuce/connection 9 파일 1,473 LOCRedisTopologyClientFactory 600, RedisRuntimeOwner 316, SentinelFailoverObserver 153, RedisConnectionRegistry 142 …
"cache / session / idempotency / rate limit / lease semantic port — API 구현 없음" cache·idempotency·lease·ratelimit·realtime·keyspace 14 파일 3,295 LOCRedisIdempotencyStoreAdapter 529, RedisCacheRegionAdapter 428, RedisDistributedLeaseAdapter 403 … 각각 전용 test 보유
"role-aware health·readiness contributor — 없음" RedisHealthContributor 88 + RedisCorrectnessRoles 62, 그리고 auto-configuration이 redisOptional·redisRequired 두 bean을 만든다
"아래 절들은 이전 세대 semantic adapter 세트의 설계 결정을 기록한 것이며, 그 코드는 현재 이 leaf에 없다" 위 14 파일이 그 코드다

두 번째 열("Spring composition 구현")도 마찬가지다. README:3032는 "현재 APP_REDIS_ENABLED=true가 하는 일은 RedisSdkSettings를 bind하고 cross-field 규칙을 fail-fast로 검증하는 것까지다. client, connection, gateway, semantic adapter, health contributor는 아직 조립되지 않는다"라고 적는다. 그러나 RedisSdkAutoConfiguration@Bean 메서드 일곱 개를 갖는다 — settings(83), validation(101), resolved credentials(156), runtime client(226), runtime owner(273, destroyMethod="close"), redisOptional(297), redisRequired(324). test가 그것을 확인한다 — clusterBuildsAClusterClient, theRuntimeOwnerFollowsTheContext, theOptionalContributorReportsUp, closingTheContextTearsEverythingDown, aLeaseReachesTheServer. 실제로 조립되지 않는 것은 gateway와 semantic adapter 둘뿐이다.

build.gradle의 주석도 같은 방향으로 틀렸다.

"The SDK itself imports nothing from them today (0 imports across main source) — the semantic cache/session/idempotency/rate-limit adapters that did were removed and are restored by Phase E …"

실측은 다르다(159-... §8.4b).

import dev.caskeleton.application.*  →  7 파일
  RedisIdempotencyStoreAdapter 18 · RedisDistributedLeaseAdapter 10 · RedisCacheRegionAdapter 9
  RedisConnectionRegistryAdapter 5 · RegistrationCodec 4 · RedisEphemeralFanoutAdapter 3 · RealtimeKeys 2
import dev.caskeleton.shared.*       →  3 파일 (ratelimit 3종)
import …adapter.outbound.support     →  0 파일

같은 주석의 다른 절반은 맞다 — spring-data-redisio.micrometer import는 실제로 0이다(exit=1).

판정: P2. 코드 결함이 아니라 문서 결함이지만 이 저장소 기준으로는 무겁다. 첫째, 이 leaf의 README는 "readiness는 서로 다른 세 가지 질문이며 하나로 합치면 안 된다"는 문장으로 시작하는, 정직한 readiness 보고를 자기 주제로 삼는 문서다. 둘째, 방향이 이례적이다 — 보통의 drift는 없는 것을 있다고 하는데 여기는 있는 것을 없다고 한다. fork가 이미 있는 4,900 LOC를 다시 구현하거나, 조립되지 않은 채 존재하는 코드의 존재 자체를 모르게 된다. 셋째, RedisSdkAutoConfiguration의 javadoc이 "until this class existed the method had no production caller at all"이라고 적는 것으로 보아 이 클래스가 README 문장보다 나중이다 — 조립이 진행됐는데 서술이 따라가지 않았다.

6. P2 — startup probe가 production에서 한 번도 실행되지 않는다

RedisStartupProbe의 javadoc은 자신을 이렇게 소개한다 — "Asks the server what it is, once, at startup. Configuration says what the deployment intends; only the server says what is true." 그것이 확인하는 것은 넷이다.

  1. 서버 버전이 7.2.0 baseline 이상인가
  2. Cluster인데 database가 0이 아닌가
  3. 명시적으로 켠 capability를 서버가 실제로 갖고 있는가 — "A capability the deployment explicitly enabled but the server does not have is a startup failure, never a silently disabled feature." 버전이 아니라 COMMAND 목록으로 확인한다(SPUBLISH·FCALL·HEXPIRE·HGETEX·XACKDEL·XNACK·JSON.SET·FT.SEARCH·TS.ADD·BF.ADD 10종 witness)
  4. 복제 배포의 쓰기 내구성min-replicas-to-write ≥ 1 그리고 min-replicas-max-lag ≥ 1

네 번째의 javadoc은 이 저장소에서 본 가장 구체적인 사고 기록이다.

"When a primary is superseded by a promotion it does not find out immediately, and until it does it keeps answering +OK to writes that are discarded when it resyncs from the new primary. The Sentinel lane measured eleven seconds and 2,086 acknowledged-then-discarded writes, with exactly one command failing. No client can see it: the server answered, so the driver, this SDK, and the caller all record a success. There is no metric to add, no failure to retry, and no certainty value that describes it."

그리고 결론을 못박는다 — "So a replicated deployment without it is a startup failure rather than a warning."

그 startup failure는 일어나지 않는다. RedisStartupProbeRedisCapabilityProbe를 참조하는 main 코드는 RedisSdkSettings:888의 javadoc {@link} 하나뿐이고, 생성하거나 confirm(...)을 부르는 곳은 전부 test다(159-... §8.1d). RedisSdkAutoConfiguration은 클라이언트와 runtime owner와 두 health contributor를 만들지만 startup probe는 부르지 않는다. 그래서 조립된 애플리케이션에서

  • 7.2 미만 서버로 시작해도 startup은 통과하고,
  • app.redis.capabilities로 켠 capability가 서버에 없어도 startup은 통과하며,
  • min-replicas-to-write가 없는 Sentinel/Cluster 배포도 startup은 통과한다 — 위 인용이 설명한 정확히 그 상황에서.

판정: P2. 로직은 완성돼 있고 test 두 파일(RedisCapabilityProbeTest 6케이스 · RedisStartupProbeTest)이 각 규칙을 고정한다. 없는 것은 호출 지점 하나다. 수정은 RedisSdkAutoConfigurationRedisStartupProbe를 실행하는 bean을 추가하는 것이고, RedisStartupProbe가 연결을 들고 있지 않도록 설계된 것(ServerFacts를 인자로 받는다) 자체가 그 조립을 쉽게 만들어 둔 형태다. 다만 INFO/CONFIG GET이 admin-plane이라는 javadoc의 지적대로, 그 조립은 admin 계정이 설정된 배포에서만 완전하다 — ServerFacts.frommin-replicas-*가 없으면 "grant the admin account +config|get"이라고 실패하는 것도 그 때문이다.

7. P3/기록 — permit 발급 권한도 production 생성 0

ConfiguredRedisPolicyAuthority(정책 이름별 HMAC 서명 permit 발급)와 ConfiguredRedisPermitVerifier(구현 타입·발급자 신원·서명·정책 이름 네 가지를 모두 확인)는 설계가 촘촘하다 — permit 구현 record 3종이 package-private이라 애플리케이션 코드가 인터페이스는 구현할 수 있어도 그 타입을 만들 수 없고, 검증기가 서명뿐 아니라 구체 타입도 본다. 서명 비교는 MessageDigest.isEqual로 상수시간이다. javadoc이 경계도 정직하게 긋는다 — "It is not a security boundary against a hostile process — the Redis ACL account is, and a permit never widens it."

그러나 이 둘을 main에서 생성하는 코드는 없다(159-... §8.1d). 참조는 전부 test다. 지금은 gateway 자체가 조립되지 않으므로(§5) 모순은 아니다 — permit은 gateway가 요구하는 것이고 gateway가 없으면 발급자도 필요 없다. 기록해 두는 이유는 §5·§6과 같은 모양이기 때문이다: 완성된 절반과 조립되지 않은 절반.

8. Negative-space probes — sub-scope 01

  • 8.1 reachability: auto-configuration의 @Bean 7개 확인. startup probe·capability probe·permit authority·verifier는 production 생성 0(§6, §7).
  • 8.1b 교차 모듈 계약: RedisCorrectnessRoles를 app-bootstrap이 실제로 읽는지 확인 — 읽는다(§4).
  • 8.2 조건부 형제: redisOptional(DEGRADED, readiness 밖)과 redisRequired(DOWN, readiness 안, 역할 조건부)의 대비가 양쪽 모두 구현·test됨(§4).
  • 8.3 기본값이 가리키는 리소스: raw allowlist 기본값이 없는 파일을 가리키고, 그것이 startup 실패로 닫혀 있음(§3).
  • 8.4 문서/수치 drift: README 4행 중 3행, build.gradle "0 imports" 주장(§5).

9. Sub-scope 01 findings backlog

우선순위 finding reachability
P2 README readiness 표 3행과 "그 코드는 이 leaf에 없다"가 사실과 다르고, build.gradle의 "0 imports across main source"도 실측 7+3 파일과 어긋난다. auto-configuration은 client·runtime owner·health contributor 2종을 실제로 만든다 이 leaf를 읽는 모든 fork
P2 RedisStartupProbe/RedisCapabilityProbe의 production 호출자 0 — 7.2 baseline·Cluster db0·명시 capability·복제 쓰기 내구성 네 guardrail이 조립된 애플리케이션에서 실행되지 않는다 Redis를 켠 모든 배포, 특히 복제 배포
P3/기록 ConfiguredRedisPolicyAuthority·ConfiguredRedisPermitVerifier production 생성 0 (gateway 미조립과 일관되나 발급 경로가 없다) gateway 조립 시점

10. Sub-scope 01 완료 조건

  • denominator 26 / 26 FULL_READ (159-... OWNED FILES)
  • §8.1~§8.4 네 종 probe 수행, 교차 모듈 계약 1건 검증
  • 후보 finding 1건(raw allowlist 기본 리소스 부재)을 startup 검사로 추적해 결함 아님으로 판정(§3)
  • 소스 미변경

11. Sub-scope 02 범위와 denominator

내부 상태: COMPLETE — 83 / 83 FULL_READ 범위: sdk/api/operations 69 + sdk/api 루트 6 + sdk/api/codec 3 (main 78, 3,802 LOC) + sdk/api 전용 test 5 역할: 이 SDK가 무엇을 표현할 수 있게 하는가 — 타입 있는 명령 표면과 그 위의 값 타입

manifest와 probe: evidence/raw/160-cache-redis-api-surface-probes.txt.

12. 설계의 중심은 "위험한 명령을 부를 수 없게 만드는 것"

이 계층의 규칙은 검증이 아니라 표현 불가능성이다. 네 가지 장치가 서명에 박혀 있다.

(a) 모든 쓰기가 만료를 들고 다닌다. Expiration은 sealed interface고 변형이 셋뿐이다 — After(Duration)(양수 강제), At(Instant), 그리고 Persistent(PersistentKeyPermit permit). 즉 TTL 없는 쓰기는 permit 없이 표현할 수 없고, javadoc이 그 의도를 적는다("cannot be reached by omission"). RedisValueOperations의 javadoc이 이어 설명한다 — SETNX·SETEX·PSETEX가 없는 이유는 "so a caller cannot accidentally pick the variant that forgets the TTL."

(b) 다중 키·고비용·비가역 연산이 permit이나 budget을 요구한다.

요구 연산
MultiKeyPermit values.multiGet · keys.exists/delete/unlink/rename · lists.move · sets.move · blocking move · hyperLogLogs.count/merge · bitmaps.bitOperation · geo.searchStore
PersistentKeyPermit keys.persist · hashes.persistFields · Expiration.Persistent
AdvancedOperationPermit keys.scan(키 공간) · hashes.entries · sets.difference/intersection/union
OperationBudget 범위·페이지·전체 읽기 계열 전부 — values.append/getRange/setRange · lists.range · sortedSets.rangeBy* · geo.search · bitmaps.bitOperation · bitFields.execute · hashes.entries

(c) 폐기·위험 명령이 아예 없다. KEYS는 "not here and never will be", ZREVRANGE 계열은 SortDirection 인자로 흡수, RPOPLPUSH/BRPOPLPUSH는 명시적 source/destination side를 가진 move로, 폐기된 GEORADIUS는 bounded search로, 그리고 RedisSetOperations에는 members()가 없다 — "the size of a set is a runtime property and an API that ignores it is an outage waiting for the right key."

(d) 값 타입이 모순된 조합을 거부한다. SortedSetAddOptions는 absent+present, greater+less, absent+score비교 세 조합을 거부한다("a score comparison is meaningless for a member that must be absent"). GeoSearchRequest는 origin/fromMember 정확히 하나, radius/box 정확히 하나, box는 width와 height 둘 다를 강제한다. BatchItemResult는 값과 실패 중 정확히 하나. BitFieldSubcommand는 signed 64 / unsigned 63 비트 상한과 "읽기는 피연산자를 갖지 않는다"를 강제한다. GeoPoint는 위도를 Redis의 실제 한계인 ±85.05112878로 제한한다.

정직한 한정도 각 인터페이스 머리에 붙어 있다 — HyperLogLog은 "roughly 0.81% standard error… unsuitable for billing, quota enforcement, or anything a user can dispute", Pub/Sub은 "at-most-once… Do not build order, payment, or retry workflows on this", 스트림 소비자 그룹은 "at-least-once… the SDK does not hide that."

13. Confirmed — "설계상 부재" 주장 6건이 구현·정책 계층까지 일치한다

주장이 API 문서에만 있는지 확인했다(160-... §8.4).

  • sdk/api 전체에서 SETNX·SETEX·PSETEX·ZREVRANGE·RPOPLPUSH·BRPOPLPUSH·GEORADIUS가 등장하는 곳은 "없다"고 적는 javadoc 네 줄뿐이다.
  • Lettuce 구현 계층에서 걸린 둘은 무해하다 — HashOperationRequests:128HSETNX(다른 명령이다), WritePresence:6의 주석("This is what replaces SETNX and SETEX").
  • 명령 정책 SSOT(redis-command-policy.yml, 1,406줄)에서 KEYS는 **risk: R4, support: BLOCKED**이고, 파일 머리의 표에 따르면 BLOCKED의 access는 NONE이다. 같은 자리에 RANDOMKEY(R2 BLOCKED)·DUMP·RESTORE·MIGRATE·SELECT·SWAPDB도 BLOCKED다. 즉 raw gateway로도 KEYS에 닿을 수 없다.

정책 파일 자체의 구조는 sub-scope 05에서 다룬다 — 머리 주석이 "Official server metadata … decides what a command is. This file decides what this SDK is willing to do with it. The catalog drift gate compares the two and fails the build when the server grows a command this file has not judged."라고 적는다.

14. Confirmed — 두 프로그래밍 모델의 대칭이 기계 검사되고, 검사기 자신도 검사된다

ReactiveRedisOperations는 "Mirrors RedisOperations method for method"라고 주장한다. ApiParityTest가 그것을 반사로 강제한다 — PAIRS 맵에 14쌍의 sync/reactive 인터페이스를 놓고 everySyncOperationHasReactiveCounterpart, everyTypedSurfaceIsInParity, theTwoEntryPointsExposeTheSameStructureAccessors, everyReactiveMethodReturnsAPublisher를 돌린다. 두 facade의 접근자 12개는 실제로 동일하다(diff 공백).

두 가지가 특히 좋다. 첫째, 예외가 이유와 함께 목록에서 빠져 있다 — Pub/Sub은 sync가 핸들러+closeable subscription이고 reactive는 publisher 자신이 전달하며 취소로 구독을 끊으므로 "different shapes on purpose, so mechanical parity would be the wrong check for them". 둘째, theInspectorDetectsADivergentReturnShape라는 검사기에 대한 메타 test가 있다 — 대칭 검사기가 고장 나 항상 통과하는 상태를 잡는다.

15. P2 — SDK가 선언한 두 진입점에 구현이 없다

RedisOperations는 자신을 "Synchronous entry point to the typed Redis API"라고 소개하고, ReactiveRedisOperations가 그 reactive 짝이다. 두 인터페이스는 각각 12개 접근자(values()·hashes()·lists()·sets()·sortedSets()·bitmaps()·bitFields()·hyperLogLogs()·geo()·streams()·keys()·batches())를 선언한다.

둘 다 구현체가 없다. leaf 전체의 implements 전수 조사(160-... §8.1 후속)에서 개별 표면은 전부 구현돼 있다 — sync 26종(LettuceRedisValueOperations·LettuceRedisHashOperations·… LettuceRedisAdminOperations·LettuceRedisSearchOperations까지), reactive 17종. 그런데 implements RedisOperations / implements ReactiveRedisOperations0건이다.

main 안에서 두 타입을 이름으로 부르는 곳도 없다. 유일한 참조가 ReactiveRedisOperations:19의 javadoc {@link} 하나와, test인 ApiParityTest:97-98의 반사 두 줄이다.

결과적으로 이 SDK를 쓰는 코드는 RedisOperations얻을 수 없다. 12개 표면을 각각 어디선가 따로 받아야 하고, 진입점이 약속하는 "하나의 객체에서 타입 있는 표면 전체" 는 존재하지 않는다. facade에 접근자를 추가하고 reactive 짝을 맞추는 규율(§14)은 실행되고 있는데, 그 facade를 만드는 코드는 없다.

판정: P2. 데이터 위험은 없다 — 없는 타입은 잘못된 답을 주지 않는다. 위험은 API 계약의 신뢰다: 이 leaf의 공개 표면 중 가장 먼저 읽히는 두 타입이 구현되지 않은 상태이고, 대칭 test가 그 사실을 가리지 못한다(인터페이스끼리만 비교하므로). sub-scope 01의 §5와 방향이 반대이면서 원인은 같다 — 조립이 절반이다. 수정은 이미 존재하는 26개 구현을 묶는 LettuceRedisOperations / LettuceReactiveRedisOperations 두 클래스를 추가하고, ApiParityTest에 "두 facade는 구현을 가진다"는 검사를 더하는 것이다.

16. P3 — Pub/Sub 채널만 렌더 크기 검증을 받지 않는다

RedisKeyRules.requireRenderedSize(...)는 조립된 키 문자열이 설정된 최대 바이트를 넘지 않는지 본다. 세 형제 중 하나만 그것을 부른다.

타입 렌더 형태 requireRenderedSize
PubSubPattern namespace:suffixPattern 있음 (:30)
PubSubChannel namespace:entity:identifier 없음
ShardedPubSubChannel namespace:entity:identifier 없음

QualifiedRedisKey 쪽에는 rejectsARenderedKeyAboveTheConfiguredSize test가 있으므로 키 경로에서는 강제된다. 채널 이름은 Redis에서 키와 같은 문자열 공간을 쓰고 같은 상한을 받는데, 두 채널 타입만 조립 결과를 재지 않는다. 구성 요소(RedisNamespace·RedisKeyName)가 각자 토큰 길이를 제한하므로 현실적인 초과는 어렵지만, 검사가 패턴에는 있고 채널에는 없다는 비대칭 자체가 의도로 보이지 않는다. P3.

덧붙여 PubSubChannel.render()ShardedPubSubChannel.render()는 본문이 완전히 동일하다(같은 세 조각을 같은 구분자로 잇는다). 두 타입이 분리된 이유는 전송 경로(cluster에서 슬롯 소유 shard로만 전달)이지 렌더링이 아니므로 분리 자체는 옳지만, 렌더 규칙이 두 벌로 복제돼 있어 §16의 비대칭이 생길 자리를 만들었다.

17. P3 — 다중 키 fan-in 중 HyperLogLog merge만 budget이 없다

같은 성격(여러 키를 읽어 하나에 쓰는, 비용이 입력 크기에 비례하는 연산)의 세 형제를 비교하면 요구가 다르다.

연산 permit budget
sets.difference/intersection/union Advanced + MultiKey 있음
bitmaps.bitOperation MultiKey 있음
geo.searchStore MultiKey 있음
hyperLogLogs.merge MultiKey 없음
hyperLogLogs.count(다중 키) MultiKey 없음

PFMERGE/PFCOUNT는 레지스터를 병합·추정하므로 입력 레지스터 수에 비례해 비용이 든다. 이 계층이 "비용이 입력에 비례하면 budget을 받는다"를 다른 네 곳에서 일관되게 적용한 뒤 HyperLogLog 두 곳에서만 빠뜨린 형태다. P3. 실제 위험은 낮다 — HLL 레지스터는 크기가 12KB로 고정이라 폭발 범위가 좁다. 그래서 P3이고, 기록하는 이유는 규칙의 예외가 이유 없이 존재하기 때문이다.

18. Negative-space probes — sub-scope 02

  • 8.1 reachability: 26개 sync + 17개 reactive 표면은 전부 구현되어 있고, 그것을 묶는 두 facade만 구현 0(§15).
  • 8.2 조건부 형제 ①: sync/reactive 대칭이 기계 검사되고 예외가 이유와 함께 목록화됨(§14).
  • 8.2b 조건부 형제 ②: 다중 키 fan-in 4종 중 HLL 2종만 budget 없음(§17).
  • 8.3 중복 mechanism: PubSubChannel/ShardedPubSubChannel의 동일한 render()와 그중 하나에도 없는 크기 검증(§16).
  • 8.4 문서 주장 대조: "설계상 부재" 6건을 API·구현·정책 yml 세 계층에서 확인(§13).

19. Sub-scope 02 findings backlog

우선순위 finding reachability
P2 RedisOperations·ReactiveRedisOperations 두 진입점의 구현이 0이고 production 참조도 0 — 개별 표면 43종은 전부 구현돼 있다 SDK를 쓰려는 모든 fork
P3 PubSubChannel·ShardedPubSubChannel이 렌더 크기 검증을 받지 않는다(형제 PubSubPattern은 받는다) 긴 namespace/entity/identifier 조합
P3 hyperLogLogs.merge·count가 다중 키 fan-in인데 OperationBudget을 받지 않는다(형제 3종은 받는다) 많은 레지스터를 병합하는 호출

20. Sub-scope 02 완료 조건

  • denominator 83 / 83 FULL_READ (160-... OWNED FILES)
  • §8.1~§8.4 네 종 probe 수행, 조건부 형제 비교 2건
  • "설계상 부재" 주장 6건을 API·Lettuce 구현·명령 정책 yml 세 계층에서 대조(§13)
  • 소스 미변경

21. Sub-scope 03 범위와 denominator

내부 상태: COMPLETE — 64 / 64 FULL_READ 범위: sdk/api/key 17 + error 14 + command 13 + reactive 17 (main 61, 3,343 LOC) + 전용 test 3 역할: 키가 무엇일 수 있는가 · 실패가 무엇을 말하는가 · 명령이 어떻게 기술되는가 · reactive 짝

manifest와 probe: evidence/raw/161-cache-redis-key-error-command-probes.txt.

22. 키: 렌더된 문자열을 받는 API가 존재하지 않는다

QualifiedRedisKey의 javadoc이 이 계층의 규칙이다 — "This is the only key shape the SDK accepts. There is no API that takes an already rendered key string, so namespace, slot, and size rules cannot be bypassed."

구조가 그것을 강제한다. RedisTypedKey는 9종만 허용하는 sealed interface고(ValueKey·HashKey·ListKey·SetKey·SortedSetKey·BitmapKey·HyperLogLogKey·GeoKey·StreamKey), 전부 QualifiedRedisKey + 코덱으로 구성된다. QualifiedRedisKeyRedisNamespace(토큰 3개) + RedisKeyName(entity 토큰 + identifier) + 선택적 RedisSlotTag다. 그리고 RedisKeyRenderer중괄호를 쓰는 유일한 장소라서 Cluster 해시 태그가 "the tag and nothing else"를 덮는다.

RedisKeyRules의 자기 한정도 정직하다 — 규칙은 "mechanical"이며 "Values that are indistinguishable from an ordinary surrogate identifier, such as a bare digit string, cannot be rejected here; those must be fingerprinted by the caller before they become a key part." 즉 이 검사가 PII 방지의 완결이 아님을 명시한다.

23. 실패: 재시도 가능성과 모호성이 배타로 강제된다

RedisFailureMetadata는 "Low-cardinality, payload-free description"이고, 불변식 하나가 이 SDK의 재시도 규칙 전체다.

if (retryable && ambiguousExecution) {
  throw new IllegalArgumentException("an ambiguous execution must never be marked retryable");
}

그리고 팩토리 두 개가 그 규칙을 실제 상황에 적용한다. notSent(...)retryable = readOperation으로 유도한다 — 서버에 닿지 않은 읽기는 재시도해도 안전하다. storedDataCorruption(...)일부러 notSent가 아니고, javadoc이 그 이유를 적는다.

"that factory derives retryable from readOperation, so a corrupt or unreadable stored value came back marked "safe to retry" purely because reading it was a read. Retrying a decode of the same bytes produces the same failure — the value is wrong, not the attempt — and a caller that treats it as transient turns one bad key into a retry loop instead of surfacing the corruption."

그 팩토리는 실제로 쓰인다 — JsonEnvelopeFraming:202, VersionedJsonCodec:103 두 곳(sub-scope 05 범위)이 디코딩 실패에서 호출한다.

예외 계층은 12종이고 전부 RedisOperationException을 상속한다. 메시지는 reason + command= 계열 + mode= + ambiguous=만 조립하고, javadoc이 경계를 적는다 — "keys, fields, members, values, arguments, and authentication material never appear."

24. 명령 기술: 정책 파일과 서버 메타데이터의 접합점

RedisCommandDescriptor는 "the join between official server metadata and organization policy"이고, 그 아래를 못박는다 — "Nothing downstream of the guard is allowed to re-derive risk, access, or timeout from a command name." 생성자가 그 접합의 모순 네 가지를 거부한다.

불변식 의미
BLOCKEDaccess == NONE 차단된 명령은 ACL 계정을 갖지 않는다
R4BLOCKED 최고 위험은 반드시 차단
R3ADMIN_ONLY 또는 BLOCKED 관리 위험은 애플리케이션에 열리지 않는다
쓰기 ∧ retrySafemayBeAmbiguous ⇒ 거부 모호할 수 있는 쓰기를 재시도 안전으로 선언 불가

마지막 하나가 §23의 런타임 불변식과 같은 규칙의 선언 시점 짝이다 — 하나는 정책 파일이 거짓말하지 못하게 하고, 하나는 실패 객체가 거짓말하지 못하게 한다.

KeySpec은 공식 Redis 규약(1-based, 음수 lastKey는 뒤에서부터, movable은 정적 유도 불가)을 그대로 따르고, movable이면 resolvePositions가 던진다 — "movable key specification must be resolved by the server". CommandId는 항상 대문자로 정규화해 "a policy file, a server metadata reply, and an SDK call site cannot disagree because of casing."

permit 세 종은 인터페이스이고 javadoc이 경계를 명확히 한다 — "Application code may implement this interface, but a self-made instance never passes RedisPermitVerifier… The final enforcement boundary remains the Redis ACL account, which a permit never widens." PersistentKeyPermit은 한 줄 더 붙인다: "Cache, session, lock, idempotency, and rate-limit APIs never accept this permit."

OperationBudget도 규칙을 문서로 못박는다 — "Every R2 API requires a budget. The budget is never optional and never defaulted, because the whole point is that the caller states the cost it is prepared to pay before Redis is asked."

25. Confirmed — sync/reactive 대칭이 값 타입 수준까지 유지된다

§14에서 확인한 facade 대칭에 더해, 개별 인터페이스 하나를 메서드 이름 집합으로 직접 대조했다 — RedisValueOperations vs ReactiveRedisValueOperations의 메서드 이름 diff가 공백이다(161-... §8.2). byte[] getRange(...)Mono<byte[]> getRange(...)로 정확히 대응한다.

반환 형태의 번역 규칙도 일관된다 — Optional<V>Mono<V>(비어 있음이 부재), List<V>Flux<V>, voidMono<Void>, OptionalDouble/OptionalLongMono<Double>/Mono<Long>. Pub/Sub만 형태가 다르고 그 예외가 ApiParityTestPAIRS에서 이유와 함께 빠져 있다(§14).

26. P3 — requireIdentifier의 다섯 검사 중 둘은 도달할 수 없다

if (value == null || !IDENTIFIER.matcher(value).matches()) { throw 생략된 문자 클래스 메시지 }
String lowerCase = value.toLowerCase(Locale.ROOT);
if (value.indexOf('@') >= 0)                      { throw "…must not contain a mail address"; }
if (JSON_WEB_TOKEN.matcher(value).matches())      { throw "…must not contain a JSON web token"; }
if (INTERNATIONAL_PHONE.matcher(value).matches()) { throw "…must not contain a phone number"; }
if (lowerCase.startsWith("bearer") || lowerCase.startsWith("eyj")) { throw "…authentication material"; }

IDENTIFIER^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$다. 이 문자 클래스에는 @+도 없다.

  • @를 포함한 값은 첫 검사에서 이미 탈락한다 → 메일 주소 분기는 도달 불가.
  • INTERNATIONAL_PHONE^\+\d[\d.~-]{7,}$로 반드시 +로 시작하는데, +는 첫 문자로도 이후 문자로도 허용되지 않는다 → 전화번호 분기도 도달 불가.

나머지 셋은 살아 있다 — JWT 패턴의 문자(A-Za-z0-9_-.)는 모두 IDENTIFIER가 허용하므로 128자 이하 JWT 형태는 문자 클래스를 통과해 전용 검사에 닿고, bearer/eyj 접두 검사도 마찬가지다.

test가 이 사실을 가리지 않는다는 점이 중요하다. rejectsEmailInIdentifiernew RedisKeyName("user", "person@example.com")IllegalArgumentException이기만 하면 통과하고, rejectsInternationalPhoneNumbersInIdentifier"+821012345678"에 대해 타입만 본다. 둘 다 문자 클래스 검사 때문에 초록이고, 전용 분기가 지워져도 초록으로 남는다.

판정: P3. 보안 효과는 그대로다 — 두 형태 모두 거부된다. 잃는 것은 진단 품질(운영자가 "must not contain a mail address" 대신 일반적인 문자 클래스 메시지를 본다)과, 두 분기가 실제로는 아무 일도 하지 않으면서 검증이 다섯 겹인 것처럼 보이게 만드는 점이다. 수정은 두 분기를 지우고 문자 클래스 메시지에 그 의도를 포함시키거나, 검사 순서를 뒤집어 구체적 형태를 먼저 판정하는 것이다.

27. P3/기록 — 선언되었으나 읽히지 않는 것 셋

  • RedisRiskLevel.requiresPermit() — 저장소 전체에서 호출자 0(161-... §8.1b). javadoc은 "the level requires an issued permit and an operation budget"라는 R2 규칙을 선언하지만, 실제 강제는 정책 파일의 required-policy 필드와 CommandPolicyGuard가 한다. 형제 deniedToApplications()CommandPolicyGuard:126RawCommandApprovals:116 두 곳에서 읽힌다 — 같은 enum의 두 술어 중 하나만 배선돼 있다.
  • RedisOperationException.metadatatransient — 예외는 serialVersionUID를 선언해 직렬화 가능한데, RedisFailureMetadataOptional 필드를 갖는 record라 직렬화 대상이 될 수 없어 transient가 맞는 선택이다. 결과로 직렬화 왕복을 거친 예외는 metadata()null을 돌려주고, 그 사실이 javadoc에 없다. 원격 호출로 예외를 전달하는 fork에서만 문제가 된다.
  • TypedRedisKeys의 slot-tag 변형이 두 종뿐valueWithSlot·setWithSlot만 있고 hash·list·sortedSet·bitmap·hyperLogLog·geo·stream에는 없다. QualifiedRedisKey.tagged(...)와 각 record 생성자로 우회할 수 있으므로 표현 불가능은 아니지만, Cluster에서 다중 키 연산을 쓰려면 슬롯 고정이 필요한 타입(예: 두 list 사이의 move, 여러 HLL의 merge)에 편의 생성자가 없다.

28. Negative-space probes — sub-scope 03

  • 8.1 도달 가능성: requireIdentifier 다섯 분기 중 둘이 선행 문자 클래스에 가려 도달 불가(§26).
  • 8.1b 선언 대비 독자: requiresPermit() 0, deniedToApplications() 2(§27).
  • 8.2 조건부 형제: sync/reactive 메서드 이름 집합 직접 diff — 차이 없음(§25).
  • 8.2b 선언 시점 ↔ 런타임 불변식: descriptor의 "모호한 쓰기는 retry-safe 불가"와 metadata의 "모호 실행은 retryable 불가"가 같은 규칙의 두 시점(§24, §23).
  • 8.3 팩토리 분화: notSentstoredDataCorruption의 분리가 실제 호출자 2곳으로 이어짐(§23).
  • 8.4 문서 주장: "렌더된 키를 받는 API가 없다"를 sealed 계층과 renderer 단일 지점으로 확인(§22).

29. Sub-scope 03 findings backlog

우선순위 finding reachability
P3 RedisKeyRules.requireIdentifier의 메일·전화 분기가 선행 문자 클래스 때문에 도달 불가 — test는 타입만 보므로 분기를 지워도 초록 진단 메시지 품질
P3/기록 RedisRiskLevel.requiresPermit() 호출자 0 (형제 deniedToApplications()는 2곳) 규칙 강제 위치의 분산
P3/기록 RedisOperationException.metadatatransient라 직렬화 왕복 후 metadata()가 null이고 그 사실이 문서에 없다 예외를 원격 전달하는 fork
P3/기록 TypedRedisKeys의 slot-tag 편의 생성자가 value·set 두 종뿐 Cluster 다중 키 사용 시점

30. Sub-scope 03 완료 조건

  • denominator 64 / 64 FULL_READ (161-... OWNED FILES)
  • §8.1~§8.4 probe 수행, 조건부 형제 비교 2건(sync/reactive · 두 술어)
  • 후보 finding 1건(reactive getRange 누락 의심)을 직접 diff로 추적해 오탐으로 판정(§25)
  • 소스 미변경

31. Sub-scope 04 범위와 denominator

내부 상태: COMPLETE — 95 / 95 FULL_READ 범위: sdk/lettuce/operations main 67 (10,718 LOC) + 전용 test 28 (약 8.3k LOC) 역할: 타입 있는 API를 실제 명령으로 바꾸는 층 — driver seam · request builder · 실행자 · in-memory double

manifest와 probe: evidence/raw/162-cache-redis-gateway-probes.txt.

32. 이 층의 구조 — 네 겹이 각자 하나씩만 안다

Lettuce{,Reactive}Redis*Operations   ← 공개 인터페이스 구현. executor 선택만 다르다
        ↓ (같은 인스턴스)
*OperationRequests                   ← 키 렌더 · 값 인코딩 · permit · budget · CommandRequest 조립
        ↓
Sync/ReactiveRedisCommandExecutor    ← CommandPolicyGuard · 타임아웃 · 관측 (sub-scope 05)
        ↓
RedisCommandGateway (114 메서드)      ← 명령당 하나. 이미 렌더된 키와 인코딩된 값만 받는다
        ↓
LettuceRedisCommandGateway           ← Lettuce 타입이 갇히는 유일한 지점

금지된 표면이 실제로 없다. RedisCommandGateway의 javadoc이 "There is deliberately no method that accepts a command name, so the forbidden execute(String, byte[]...) surface cannot appear here either"라고 적고, 전수 검색 결과 execute(String·command(String·dispatch(String 매치는 그 문장 자신뿐이다(162-... §8.1). 인터페이스는 CompletionStage를 반환하는 114개 명령별 메서드로만 이루어져 있다.

seam의 존재 이유도 적혀 있다 — "so the typed operations can be proven against a deterministic in-memory server without Docker, and so the Lettuce types stay inside LettuceRedisCommandGateway."

33. Confirmed — 두 프로그래밍 모델이 같은 request builder를 공유한다

ValueOperationRequests의 javadoc이 불변식을 적는다 — "Both the blocking and the reactive string operations call exactly these methods, so a change to a permit, a budget, an encoding, or a command choice cannot apply to one API and not the other."

구조가 그것을 보장한다. LettuceRedisValueOperationsLettuceReactiveRedisValueOperations는 둘 다 생성자에서 new ValueOperationRequests(gateway, context, counters)를 만들고, 차이는 SyncRedisCommandExecutor vs ReactiveRedisCommandExecutor 하나뿐이다. 각 메서드는 executor.execute(requests.xxx(...)) 한 줄이고, reactive 쪽은 그 위에 flatMap/then 같은 형태 변환만 얹는다.

11개 계열 전부에서 확인했다(162-... §8.2) — Value·Hash·List·Set·SortedSet·Key·Geo·Bitmap·Stream·HyperLogLog·PubSub 모두 sync와 reactive 양쪽이 같은 *OperationRequests를 생성한다.

34. Confirmed — 규칙이 RedisOperationContext 한 곳에 모여 있다

javadoc이 역할을 정확히 적는다 — "It renders keys, encodes values against the value ceiling, verifies the permits the guard does not see because their command is R1, and issues the SDK-side half of an R2 admission. It holds no connection and no driver type, so both the blocking and the reactive operations share exactly one copy of these rules."

세부가 촘촘하다.

  • parseKey의 엄격함. SCAN이 돌려준 키를 다시 해석하는 유일한 지점이고, 네임스페이스 밖이거나 슬롯 태그가 안 닫혔거나 키 문법을 안 따르면 거부한다. 이유가 적혀 있다 — "an anomaly, not something to silently drop from a page and let a caller mistake for an empty result."
  • scanBudget의 overshoot 허용. COUNT는 힌트라서 Redis가 버킷 단위로 더 돌려줄 수 있고, 실제로 "a real server returned 501 for exactly that request"였다. 그래서 예산을 요청 count에 맞추면 정상 응답을 거부하게 된다. 대신 설정된 scan 상한 + 512를 허용하되 "a server returning an order of magnitude more than it was asked for is still refused."
  • requirePersistentKeyPermit. TTL 없는 쓰기와 PERSIST는 R1이라 guard가 permit을 보지 못한다. 그래서 여기서 검증하는 것이 "a key without an expiry is a deliberate, authorized decision"을 advisory가 아니라 참으로 만든다.
  • sdkMultiKeyPermit의 이중 permit 처리. BLMOVE처럼 본질적으로 다중 키인 명령은 caller가 multi-key-write를 증명하지만 guard는 blocking-pop 정책으로 admit한다. caller의 permit을 guard에 넘기면 엉뚱한 이유로 provenance가 실패하므로, caller의 권한은 context에서 검증하고 guard에는 SDK 자신의 permit을 준다. javadoc이 그 설계를 그대로 적고 마지막 줄에 안전성을 못박는다 — "The caller still cannot reach the fan-out without holding a real multi-key permit."

응답 예산은 실제로 강제된다 — requireReplyWithinBudget이 main에서 12곳, 8개 request builder(Bitmap·Geo·Hash·Key·List·Set·SortedSet·Value)에서 호출된다.

35. Confirmed — guard를 지나지 않는 경로가 하나 있고, 그것이 선언돼 있다

PubSubOperationRequests의 javadoc이 예외를 스스로 밝힌다.

"Publishing is an ordinary command and goes through the guard. Subscribing is not: it has no reply to bound and no timeout to apply, so what the guard would have checked is checked here — the namespace of every target, and the R2 permit a pattern subscription needs because its fan-out is decided by the server, not by the request."

대체 검사가 실제로 있다. channelTargets·shardTargets·patternTargets 셋 다 빈 컬렉션을 거부하고 모든 대상의 네임스페이스가 이 프로세스의 것과 같은지 확인한다(requireNamespace, 다르면 "channel belongs to a namespace this process may not use"). patternTargets는 추가로 context.sdkPermit(PATTERN_SUBSCRIBE)를 호출한다.

36. P3 — 패턴 구독의 R2 승인만 호출자가 아니라 배포에 대해 이루어진다

§35의 마지막 줄이 다른 R2 연산과 다르다.

R2 연산 호출자가 permit을 들고 오는가
sets.difference/intersection/union — 서명이 AdvancedOperationPermit을 요구
keys.scan · hashes.entries
bitFields.execute budget 필수, permit은 guard가 catalog의 required-policy로 요구
pubSub.patternSubscribe 아니오 — 서명에 permit 인자가 없다

patternTargets가 부르는 context.sdkPermit(PATTERN_SUBSCRIBE)SDK가 자기 자신에게 발급하는 경로다. ConfiguredRedisPolicyAuthority.issueAdvanced는 정책 이름이 배포의 enabledPolicies에 없으면 던지므로, 실제 효과는 "이 배포가 pattern-subscribe를 켰는가"를 확인하는 것이다. 그리고 반환된 permit은 버려진다.

즉 다른 R2 연산은 호출 지점이 승인을 증명하는데, patternSubscribe배포가 켜 두었는지만 본다. javadoc이 permit이 필요한 이유("its fan-out is decided by the server")는 적지만, 그 permit이 호출자가 아니라 SDK가 스스로 발급한 것이라는 약해진 보증은 적지 않는다. AdvancedOperationPermit의 계약이 "proving that an R2 operation was explicitly approved"인 것과 견주면 차이가 있다.

판정: P3. 배포 수준 게이트는 실재하고 네임스페이스 봉쇄도 있으므로 열린 구멍은 아니다. 기록하는 이유는 같은 위험 등급에 두 가지 다른 승인 모델이 적용되고 그 차이가 문서화돼 있지 않기 때문이다. 수정은 patternSubscribe 서명에 AdvancedOperationPermit을 추가하거나, javadoc에 "배포 수준 승인"임을 명시하는 것이다.

37. P3 — permit 정책 이름이 세 곳에 문자열로 존재하고 교차 검사가 없다

정책 이름의 출처가 셋이다.

출처 개수
RedisOperationContextpublic static final String 상수 18
redis-command-policy.ymlrequired-policy: 18
LettuceRedisSearchOperations:31의 private 상수 SEARCH_INDEX 1

두 집합의 차분은 정확히 둘이고 양쪽 다 설명이 있다(162-... §8.3).

  • persistent-key는 Java에만 있다 — 해당 명령들이 R1이라 catalog의 required-policy가 아니라 RedisOperationContext.requirePersistentKeyPermit이 강제한다(§34).
  • search-index는 YAML에만 있다 — FT.CREATE(R2, ADVANCED_TYPED, 최소 8.0)의 required-policy이고, Java 쪽 짝은 RedisOperationContext가 아니라 sdk/extensions/search의 private 상수다.

문제는 차분이 아니라 차분을 감지하는 장치가 없다는 것이다. YAML에 required-policy: bounded-collectoin-read처럼 오타가 들어가면 그 명령은 아무도 발급받을 수 없는 정책을 요구하게 되고, Java 상수 쪽에 오타가 들어가면 issueAdvanced가 "policy is not enabled"로 던진다. 어느 쪽도 빌드를 깨지 않는다. catalog drift gate는 YAML을 서버 메타데이터와 대조하지, Java 상수 집합과 대조하지 않는다.

판정: P3. 확정은 sub-scope 05로 이월한다 — RedisCommandPolicyLoaderTest가 정책 이름 집합을 검사하는지 그 sub-scope에서 확인한다.

38. Confirmed — in-memory double이 같은 인터페이스를 구현한다

InMemoryRedisCommandGateway(test, 2,456 LOC)는 implements RedisCommandGateway다. 즉 114개 메서드를 컴파일러가 강제하므로, 실제 gateway에 명령이 추가되면 double도 따라가지 않을 수 없다. seam의 목적("proven against a deterministic in-memory server without Docker")이 타입으로 보장된다.

이것이 이 leaf의 test 전략을 설명한다 — sdk/lettuce/operations 아래 test 28개 중 대부분이 계약 test(Redis*OperationsContractTest)이고, 같은 계약을 live lane(LiveRedis*Test)이 실제 서버에 대해 다시 돌린다.

39. Negative-space probes — sub-scope 04

  • 8.1 금지 표면: 명령 이름을 받는 메서드 0, 114개 명령별 메서드만 존재(§32).
  • 8.2 조건부 형제: 11개 계열 전부에서 sync/reactive가 같은 request builder를 생성(§33).
  • 8.2b 승인 모델 형제: R2 중 patternSubscribe만 호출자 permit이 아닌 배포 permit(§36).
  • 8.3 중복 mechanism: 정책 이름이 Java 상수 18 + YAML 18 + 별도 private 상수 1, 교차 검사 없음(§37).
  • 8.4 선언된 예외: guard를 지나지 않는 구독 경로가 선언돼 있고 대체 검사가 실재(§35). 응답 예산 강제 12곳(§34). double이 인터페이스 구현으로 강제됨(§38).

40. Sub-scope 04 findings backlog

우선순위 finding reachability
P3 patternSubscribe가 R2인데 호출자 permit을 요구하지 않고 SDK 자기 발급 permit으로 배포 수준만 확인한다 — 다른 R2 연산과 승인 모델이 다르고 문서에 그 차이가 없다 패턴 구독을 쓰는 배포
P3 permit 정책 이름이 Java 상수 18 · YAML 18 · 별도 private 상수 1로 흩어져 있고 두 집합의 일치를 검사하는 장치가 없다 (sub-scope 05에서 확정) 정책 이름 오타

41. Sub-scope 04 완료 조건

  • denominator 95 / 95 FULL_READ (162-... OWNED FILES) — main 10,718 LOC 전수 판독
  • §8.1~§8.4 probe 수행, 조건부 형제 비교 2건, 중복 mechanism 1건
  • 후보 finding 1건(InMemoryRedisCommandGateway의 메서드 누락 의심)을 implements 관계로 추적해 오탐으로 판정(§38)
  • 정책 이름 교차 검사 여부는 sub-scope 05로 이월(§37)
  • 소스 미변경

42. Sub-scope 05 범위와 denominator

내부 상태: COMPLETE — 44 / 44 FULL_READ 범위: sdk/lettuce/command 14 + connection 9 + codec 7 + observability 2 (main 32, 4,294 LOC) + 전용 test 11 + golden resource 1 역할: 명령 입장 심사 · 연결 레인과 수명 · 코덱 · 관측. 그리고 1,406줄짜리 명령 정책 SSOT를 읽는 파서

manifest와 probe: evidence/raw/163-cache-redis-guard-connection-codec-probes.txt.

43. CommandPolicyGuard — 순서가 고정된 단일 입장 지점

javadoc이 순서와 그 이유를 적는다 — "Validation order is fixed and each step is cheaper than the one after it, so an obviously inadmissible command is refused before anything is encoded or sent."

capability → risk/permit provenance → namespace → slot → request budget
  → connection lane → timeout/retry → invocation → reply budget → translation → telemetry

각 단계가 구체적이다. requireReachableBLOCKED거나 access == NONE이면 거부하고 R3/R4를 애플리케이션 경로에서 배제한다. requireCapability는 명령의 최소 버전을 프로브된 서버 버전과 대조한다. requireNamespace는 모든 키의 네임스페이스를 확인하고 렌더까지 수행한다. requireSameSlot은 Cluster에서 두 개 이상 슬롯이면 RedisCrossSlotException서버를 부르기 전에 던진다. effectiveTimeout은 블로킹 명령이 유한한 server block을 선언하지 않으면 거부하고, 설정 상한을 넘으면 거부하며, 통과하면 BLOCKING_MARGIN(2초)을 더한다.

이 클래스에는 두 개의 수정 이력이 주석으로 남아 있고, 둘 다 이 저장소에서 반복해 본 종류다.

(a) 죽은 중복 mechanism을 지운 기록. validateReply(...)가 있었고 아무도 부르지 않았다.

"Two mechanisms for one rule, with the more visible one dead, is worse than one: a reader finds the guard's method, assumes replies are bounded during admission, and writes an operation that never bounds its own. Admission cannot do this job anyway. The guard runs before the command is sent, so the only reply size available to it is the estimate the request declared. The authority has to sit where the bytes actually arrive."

(b) 절대 발화하지 못하던 조건. 다중 키 permit 검사가 advanced permit 검사와 한 조건으로 접혀 있었고, "둘 다 없음"이 위에서 이미 던지므로 다중 키 절은 도달 불가였다 — "set algebra over any number of keys was admitted on an advanced permit alone." 지금은 request.keys().size() > 1 && request.multiKeyPermit().isEmpty()가 독립 조건이다. test rejectsAMultiKeyCommandCarryingOnlyAnAdvancedPermitaSingleKeyAdvancedCommandStillNeedsNoMultiKeyPermit가 양쪽을 고정한다.

44. 정책 문서를 일반 YAML 파서로 읽지 않는다

RedisCommandPolicyLoader의 javadoc이 이유를 적는다.

"It is parsed by an explicit reader rather than a general YAML engine because a general engine would silently accept anchors, merges, nested structures, and duplicate keys — exactly the constructs that could hide a risk downgrade inside a security policy file."

파서는 그만큼 좁다 — 탭 금지, 들여쓰기 0/2/4만 허용, commands: 루트 정확히 하나, 명령 블록 중복 금지, 필드 이름 allowlist(12종) 밖이면 거부, 빈 값 거부, 필드 중복 거부. test가 rejectsUnknownFieldsEnumsAndDuplicates로 잡는다.

RedisCommandPolicy/RedisCommandDescriptor의 교차 필드 불변식(§24)이 로딩 시점에 적용되므로, "R4인데 BLOCKED이 아닌" 정책 파일은 읽히지 않는다. test가 everyDestructiveCommandIsBlockedAndUnreachable·deprecatedCommandNamesAreNotReachable·arbitraryScriptSourceExecutionIsBlocked·theCatalogFailsClosedForAnUnclassifiedCommand로 그 집합을 고정한다.

45. 연결: 레인이 계정과 함께 유도되고, 종료가 순서다

RedisConnectionKind가 레인 6종(REGULAR·BLOCKING·TRANSACTION·SCRIPT·PUBSUB·ADMIN)을 정의하고, javadoc이 분리 이유를 적는다 — 실패 양식이 서로 호환되지 않기 때문이다. 더 중요한 것은 레인이 계정도 결정한다는 점이다.

"Least privilege only means something if the accounts differ. The application account runs ordinary data commands and must not be able to execute a script; the advanced account can, and exists so that a compromised request path cannot reach EVALSHA."

forCommand는 blocking이면 BLOCKING, ADMIN_READONLY면 ADMIN, NONE이면 예외다. test routesEachCommandFamilyToItsOwnLane·aBlockedCommandHasNoLane이 고정한다.

RedisRuntimeOwner는 종료를 상태 기계로 만든다 — OPEN → DRAINING → CLOSED. javadoc이 그 필요성을 과거 증상으로 설명한다 — "Without them 'closed' was a counter reset: a lease taken during shutdown still succeeded, its connection was never returned, and a second close double-counted." 지금은 draining이 시작되는 순간 lease가 거부되고, close는 멱등이며, 실패한 연결은 다음 호출자에게 넘기지 않고 무효화한다.

레인 포화는 대기가 아니라 거부다 — "An unbounded wait on a saturated lane converts one slow dependency into every caller of this process waiting on it." 그리고 Cluster에서 라우팅된 lease는 절대 풀에 넣지 않는다("a lane's pooled connection on Cluster is pinned to whichever node the previous caller routed to").

test가 이 성질들을 이름으로 잡는다 — exhaustingALaneRejectsImmediatelyInsteadOfQueueing, aSaturatedBlockingLaneNeverConsumesTheRegularLane, closingALeaseTwiceReleasesItOnlyOnce, anInvalidatedConnectionIsNotReused, closingStopsAdmissionFirst, theClientShutsDownLast, aDeadPooledConnectionIsReplaced.

46. Confirmed — 두 실행자가 같은 네 협력자를 갖는다

SyncRedisCommandExecutorReactiveRedisCommandExecutor는 생성자 인자가 동일하다 — CommandPolicyGuard, LettuceExceptionTranslator, deployment mode, Consumer<RedisObservation>. 둘 다 guard.validate(request)로 시작하고 admission.timeout()을 적용하며 NoThrowObservationSink.wrap(...)으로 sink를 감싼다.

sink를 감싸는 이유가 test 이름에 있다 — throwingSinkDoesNotFailASuccessfulSynchronousCommand, throwingSinkDoesNotReplaceTheOriginalSynchronousFailure, 그리고 reactive 짝 둘. 관측이 명령의 결과를 바꾸지 못한다는 규칙이 네 방향 모두에서 고정돼 있다.

실패 번역도 확실성 보존이 중심이다 — marksWriteTimeoutAsAmbiguousAndNotRetryable, marksReadTimeoutAsRetryableAndNotAmbiguous, marksConnectionLossAroundAWriteAsAmbiguous, treatsAnUnrecognisedWriteFailureAsAmbiguousRatherThanAsNotApplied, keepsAnUnrecognisedReadFailureRetryable, neverLeaksServerMessageDetailIntoTheSdkMessage.

관측 태그는 닫혀 있다 — neverAddsRawKeyToMetricTags, exposesTheClosedLowCardinalityTagSet, projectsTheSlotIntoALowCardinalityBucket.

코덱은 golden payload로 고정된다 — readsVersionOneGoldenPayload/writesTheSameBytesAsTheGoldenPayload(src/test/resources/redis-sdk/golden/order-summary-v1.json), 그리고 rejectsAFutureVersionInsteadOfSilentlyMisreadingIt·rejectsCorruptFraming·aDecodeFailureIsNotRetryableAndReportsTheBoundDeploymentMode가 §23의 storedDataCorruption 규칙을 실제 경로에서 확인한다.

47. P2 — "build gate"라고 불리는 catalog drift 검사가 어디에서도 실행되지 않는다

명령 정책 파일의 머리 주석이 이 leaf의 가장 강한 governance 주장을 한다.

"Official server metadata (COMMAND DOCS / COMMAND INFO / COMMAND GETKEYSANDFLAGS) decides what a command is. This file decides what this SDK is willing to do with it. The catalog drift gate compares the two and fails the build when the server grows a command this file has not judged."

RedisCommandMetadataDiff의 javadoc도 자신을 "The build gate"라고 부르고, 다섯 버킷 각각이 무엇을 막는지 적는다 — 아무도 분류하지 않은 새 명령, 사라진 명령, 키 추출이 엉뚱한 인자를 가리키게 만드는 key spec 이동, 계정을 조용히 넓히는 ACL 카테고리 변경, 타입 있는 API가 아직 노출하는 deprecation.

비교 로직은 완성돼 있고 test 여섯 개가 다섯 버킷을 전부 덮는다 — reportsACommandTheServerGrewAndThePolicyNeverClassified, reportsAKeySpecificationThatMoved, reportsAnAclCategoryChangeAgainstTheReviewedBaseline, reportsADeprecationThatIsStillApplicationReachable, reportsACommandThatDisappearedFromTheServer, reportsNoDriftWhenPolicyMatchesTheServer.

그런데 실제 서버 메타데이터를 넣는 곳이 없다. RedisCommandMetadataDiff를 참조하는 main 파일은 0개이고(163-... §8.1), test 참조도 자신의 단위 test 하나뿐이다 — 토폴로지 lane 어디에서도 쓰이지 않는다(RedisServerCommandMetadata를 포함한 검색에서 lane 매치 0). build.gradle에도 이 비교를 실행하는 태스크가 없다(§0의 lane 정의는 전부 redisTopologyTest다).

빌드를 깨는 gate는 존재하지 않는다. 존재하는 것은 gate가 쓸 비교 함수와 그 함수의 단위 test다. Redis가 명령을 추가하거나 key spec을 옮겨도 이 저장소의 어떤 실행 경로도 알려주지 않는다.

판정: P2. 데이터 위험은 즉각적이지 않다 — 분류되지 않은 명령은 RedisCommandCatalog.require가 fail-closed로 거부하므로(test theCatalogFailsClosedForAnUnclassifiedCommand) 새 명령이 조용히 통과하지는 않는다. 위험은 반대편 셋이다: key spec이 이동하면 이 SDK의 네임스페이스·슬롯 검사가 잘못된 인자를 키로 보게 되고, ACL 카테고리가 넓어지면 계정 분리 가정이 조용히 약해지며, deprecation은 타입 있는 API가 사라질 명령을 계속 노출한다. 셋 다 fail-closed 카탈로그가 잡지 못하는 종류다. 수정은 토폴로지 lane에서 COMMAND DOCS/COMMAND INFO를 읽어 RedisCommandMetadataDiff.compare(...)를 돌리고 비어 있지 않으면 실패시키는 것 — lane은 이미 실제 서버에 붙어 있고 비교 함수도 완성돼 있으므로 없는 것은 연결 한 줄이다.

48. P3/기록 — 정책 문서가 자기 필드를 하나 적지 않는다

redis-command-policy.yml 머리의 "Fields and their defaults:" 표는 11개 필드를 적는다(risk·support·minimum-version·access·blocking·read-only·retry-safe·may-be-ambiguous·timeout-profile·key-spec·required-policy).

RedisCommandPolicyLoader.FIELDS12개를 받는다 — 위 11개에 **optional-block**이 더 있다. 그리고 그 필드는 실제로 쓰인다(파일 :666, :673 두 명령). 파서는 allowlist 밖 필드를 거부하므로 문서만 뒤처져 있다.

이 sub-scope에서 sub-scope 04 §37의 이월도 확정한다. RedisCommandPolicyLoaderTest.everyAdvancedCommandDeclaresThePermitPolicyItRequires는 모든 ADVANCED_TYPED 명령이 required-policy갖는지만 확인하고, 그 이름이 Java 쪽에서 발급 가능한 정책인지는 확인하지 않는다. RedisOperationContext를 참조하는 이 sub-scope의 test도 없다. §37은 확정된 P3이다 — 정책 이름 집합의 일치를 검사하는 장치가 저장소에 없다.

49. P3/기록 — production에 있으나 production 소비자가 없는 타입 셋

타입 LOC main 참조 실제 소비자
RedisCommandMetadataDiff 145 0 자기 단위 test뿐(§47)
RedisCodecRegistry 203 0 RedisCodecRegistryTest
SentinelFailoverObserver 153 0 Sentinel 토폴로지 lane test

셋 다 sub-scope 01 §5가 확정한 "조립이 절반"의 결과다. SentinelFailoverObserver는 lane의 계측 도구라는 역할이 javadoc에 분명하므로 성격이 다르고, 그 javadoc은 이 저장소에서 본 가장 정직한 자기 한정 중 하나다 — 자신이 측정할 수 없는 손실 창을 명시하고("no counter here can be made to include it"), 그 창을 닫는 것은 서버 설정(min-replicas-to-write)임을 적으며, 같은 promotion을 그 설정으로 다시 돌렸을 때 2,086 → 1로 줄었다는 실측을 남긴다. §6의 미실행 startup probe가 강제하려던 바로 그 설정이다.

50. Negative-space probes — sub-scope 05

  • 8.1 reachability: 9종 타입의 main 참조 수 계수 — 셋이 0(§49). drift gate가 그중 하나(§47).
  • 8.2 조건부 형제: 두 실행자의 협력자 4종 동일, 관측 예외 격리가 sync/reactive 각 2 test(§46).
  • 8.3 중복 mechanism: 죽은 validateReply를 지운 기록과 그 이유(§43). 정책 이름 3중 기재의 교차 검사 부재 확정(§48).
  • 8.4 문서/수치 drift: 정책 파일 필드 목록 11 vs loader allowlist 12(§48). "build gate"라는 서술과 실행 경로 부재(§47).

51. Sub-scope 05 findings backlog

우선순위 finding reachability
P2 catalog drift gate(RedisCommandMetadataDiff)가 "fails the build"라고 선언돼 있으나 실제 서버 메타데이터를 넣는 실행 경로가 없다 — main 참조 0, lane 참조 0, Gradle 태스크 0 key spec 이동 · ACL 카테고리 확대 · deprecation
P3 permit 정책 이름 집합(Java 18 + 별도 상수 1 vs YAML 18)의 일치를 검사하는 장치가 없다 (sub-scope 04 §37 확정) 정책 이름 오타
P3/기록 정책 문서의 필드 표가 11개를 적지만 loader는 optional-block을 포함해 12개를 받고 그 필드가 실제로 쓰인다 문서
P3/기록 RedisCodecRegistry(203 LOC)·SentinelFailoverObserver(153 LOC)의 production 소비자 0 조립 완료 시점

52. Sub-scope 05 완료 조건

  • denominator 44 / 44 FULL_READ (163-... OWNED FILES)
  • §8.1~§8.4 probe 수행, 조건부 형제 비교 1건, 중복 mechanism 2건
  • sub-scope 04 §37의 이월을 확정(§48)
  • 소스 미변경

53. Sub-scope 06 범위와 denominator

내부 상태: COMPLETE — 54 / 54 FULL_READ 범위: sdk/programmability 17 + extensions/** 17 + raw 7 + cluster 5 + admin 4 (main 50, 4,415 LOC) + 전용 test 4 역할: 스크립트·트랜잭션·함수, 확장 모듈 4종, 승인된 raw 명령, 슬롯 계산, 관리 평면

manifest와 probe: evidence/raw/164-cache-redis-programmability-extensions-probes.txt.

이 sub-scope의 전용 test는 4개뿐이지만 실제 계약 test는 sub-scope 04의 test 패키지에 있다 — RedisExtensionModulesContractTest, RedisAdminPlaneContractTest, RedisTransactionContractTest, RedisTransactionSlotContractTest. 계층이 아니라 계약 단위로 test를 배치한 결과다.

54. raw gateway — "escape hatch"가 두 겹의 사전 승인으로 닫혀 있다

RedisRawGateway의 javadoc이 존재 이유와 한계를 함께 적는다 — "There is no execute(String, byte[]...) here or anywhere else in the SDK. The escape hatch exists because some commands genuinely have no typed form worth building, not because arbitrary command execution is acceptable; every one of them is named, bounded, and audited before it can be sent."

승인이 두 개의 독립된 문을 모두 통과해야 한다(RawCommandApprovals).

  1. 명령이 정책 카탈로그에서 RAW_ONLY로 분류돼 있어야 한다 — "the organization's decision about which commands may ever leave through this door"
  2. 배포가 그 명령에 대한 승인(ApprovedRawCommand)을 등록해야 한다

"Neither alone is enough, and neither is decided at request time." 그리고 R3/R4는 어느 쪽이든 거부된다.

ApprovedRawCommand배포 산출물이다 — 명령 identity, 최대 인자 수, 요청/응답 바이트 상한, 타임아웃, 응답 디코더를 프로세스 시작 전에 고정한다. 토큰은 RawCommandApprovals만 발급하고, 검증은 (a) 토큰 타입이 내부 record인지, (b) 발급 레지스트리 인스턴스가 같은지(issued.origin != this), (c) 정책 id가 일치하는지, (d) 제시된 승인이 등록된 것과 같은지 넷을 본다.

RawMovableKeys가 이 패키지에서 가장 흥미롭다. movable key spec(예: SORT)은 키 위치를 인자 목록이 결정하므로 정적으로 알 수 없고, 그러면 네임스페이스 검사를 할 수 없다. 기본은 여전히 거부다. 예외로 SORT/SORT_RO 파서 하나가 등록돼 있는데, 그 설계가 명시적이다 — "a parser that knows exactly one command shape and refuses everything else. Refusing is the important half: a parser that guesses at an unknown option would mis-locate the keys, and a mis-located key is a key the namespace check never sees."

그리고 유효한 Redis 문법인 BY/GET의도적으로 거부한다 — 둘은 키가 아니라 패턴을 받고 서버가 키 공간 전체에 대해 확장하므로 SDK가 검사할 키가 없다. "That is a deliberate capability limit, not an oversight." SORT_ROSTORE가 오면 거부하고, STORE 목적지는 실제 키이므로 위치 목록에 넣어 검사받게 한다. test 8개가 이 파서의 수용/거부를 전부 고정한다(aPlainSortHasOneKey·storeAddsTheDestination·readOnlySortCannotStore·patternsAreRefused·anUnknownOptionIsRefused·aMalformedLimitIsRefused·anUnparsableCommandIsRefused·anEmptySortIsRefused).

55. 스크립트와 트랜잭션 — 등록이 배포 단계이고, 창(window)은 노드에 고정된다

RedisScriptRegistry의 규칙 — "Registration is a deployment step, not a request-time one. A script that was never registered has no digest and therefore no way to reach the server, which is what makes 'only reviewed scripts run' a structural property rather than a convention." 같은 identity에 다른 body를 등록하면 거부하고, 실행 시점에도 body가 등록본과 같은지 다시 본다.

README가 주장하는 복구 사슬 EVALSHA → NOSCRIPT → SCRIPT LOAD → digest verify → EVALSHA실재한다LettuceRedisScriptOperations:26 javadoc이 "NOSCRIPT is the one failure retried automatically"라고 적고, :129-133RedisNoScriptException 또는 메시지 접두 NOSCRIPT를 잡아 :100에서 registry.forget(script.id())를 호출한다. 다음 호출이 digest(...)에서 다시 SCRIPT LOAD한다.

RedisTransactionRunner는 Cluster에서의 MULTI 문제를 정면으로 다룬다. javadoc이 문제와 해법을 적는다 — 다른 레인은 명령마다 슬롯 소유 노드로 라우팅하는데 "that is exactly what a MULTI window must not do: the queued commands would be spread across nodes and none of them would be part of the same window." 해법은 연결이 아니라 라우팅 결정이었다 — 감시 키(또는 명시적 슬롯 태그)에서 노드를 정해 레인을 고정한다. 감시 키가 없는 Cluster 트랜잭션은 거부하고 그 이유를 적는다 — "the keys the callback will queue are not known until the callback runs, which is after the window is open."

실패한 창의 연결은 풀에 넣지 않는다 — "the next borrower would queue their command into somebody else's MULTI." test가 aSameSlotTransactionExecutes·aCrossSlotTransactionIsRefusedBeforeSend·anUnroutableTransactionIsRefused·anExplicitSlotTagRoutesTheWindow로 네 갈래를 고정한다.

확장 4종(JSON·Search·TimeSeries·Probabilistic)은 ExtensionCommandRunner 하나를 공유한다 — "Sharing it is also what guarantees they cannot drift apart on the parts that matter: every extension command declares its key, so the guard namespace-checks and slot-checks it exactly like a classic one."

56. P3 — NOSCRIPT 복구가 다섯 벌로 구현돼 있고 넷은 스크립트 레지스트리를 지나지 않는다

README는 이 메커니즘을 하나로 서술한다 — "closed-catalog EVALSHA -> NOSCRIPT -> SCRIPT LOAD -> digest verify -> EVALSHA recovery".

실제로는 NOSCRIPT 문자열을 스스로 판정하고 자기 digest 캐시를 무효화하는 코드가 다섯 곳에 있다(164-... §8.2).

위치 자체 digest 캐시 RedisScriptRegistry 경유
sdk/programmability/LettuceRedisScriptOperations:129-133 아니오 — 레지스트리 사용
sdk/lettuce/operations/AtomicCounterScripts:190-199 아니오
idempotency/IdempotencyScripts:263 아니오
lease/LeaseScripts:161 아니오
ratelimit/RateLimitScripts:241 아니오

넷은 모두 같은 판정식(message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT"))을 각자 복사해 갖고 있고, 각자 digest를 캐시하며, RedisScriptRegistry가 강제하는 두 성질 — 동일 identity에 다른 body 등록 거부, 실행 시 body와 등록본 대조 — 를 받지 않는다.

판정: P3. 넷 다 스크립트 원본이 자기 클래스 안의 상수라 지금 body가 갈라질 여지는 좁다. 기록하는 이유는 두 가지다. (a) README가 하나의 닫힌 메커니즘이라고 서술하는 것이 실제 구조와 다르다. (b) 판정식이 다섯 벌이므로 Redis가 오류 문자열을 바꾸면 다섯 곳을 고쳐야 하고, 그중 하나를 놓치면 그 스크립트만 조용히 복구되지 않는다 — 증상은 NOSCRIPT가 애플리케이션 실패로 새어 나가는 것이다. 수정은 판정을 LettuceExceptionTranslator가 이미 만드는 RedisNoScriptException 하나로 통일하고(그 번역은 이미 존재한다: :92-93, :190-191), 네 곳이 자기 캐시 대신 레지스트리를 쓰게 하는 것이다.

57. Confirmed — 슬롯 검사 두 곳은 중복이 아니라 서로 다른 범위다

CommandPolicyGuard.requireSameSlot(모든 명령, admission 시점)과 cluster/SameSlotValidator(트랜잭션 창) 둘 다 존재한다. 중복처럼 보이지만 범위가 다르다 — guard는 한 명령의 키 집합을 보고, SameSlotValidator창이 감시·큐잉할 키 집합 전체를 본다. 창 안에서 큐잉되는 명령은 MULTI가 열린 뒤에야 정해지므로 guard의 명령별 검사로는 창 전체의 슬롯 일치를 보장할 수 없다. RedisTransactionRunner의 javadoc이 그 관계를 적는다 — "Cross-slot is still refused before anything is sent, by the same SameSlotValidator the window itself uses. Pinning a lane does not make a transaction that spans slots legal; it makes a transaction that does not span them possible."

RedisSlotCalculator는 test로 CRC16 슬롯 규칙을 고정한다 — matchesPublishedSlots, bracesControlSlotCalculation, emptyBracesHashTheWholeKey(빈 중괄호는 키 전체를 해시하는 Redis 규칙), slotsStayInRange, sameSlotRequiresAHashTag.

58. P3/기록 — 이 sub-scope의 진입 타입 다섯이 production 소비자 0

타입 LOC main 참조
LettuceRedisRawGateway 160 0
RedisTransactionRunner 139 0
LettuceRedisAdminOperations 337 0
ClusterTopologyObserver 99 0
ClusterScanCursor 115 0

내부 협력자들은 배선돼 있다 — RawCommandApprovals 2, ExtensionCommandRunner 4, RedisSlotCalculator 3, SameSlotValidator 2, RedisScriptRegistry 1. 즉 부품은 서로 연결돼 있고 진입점만 아무도 만들지 않는다. sub-scope 01 §5·02 §15와 정확히 같은 모양이고, 같은 원인(조립 절반)의 세 번째 관측이다.

59. Negative-space probes — sub-scope 06

  • 8.1 reachability: 10종 진입/협력 타입의 main 참조 계수 — 진입 5종이 0(§58).
  • 8.2 문서 주장 대조: README의 EVALSHA 복구 사슬이 실재함을 forget(...) 호출 지점으로 확인(§55), 그러나 구현이 다섯 벌임을 발견(§56).
  • 8.3 중복 mechanism: NOSCRIPT 판정과 digest 캐시 5중(§56). 슬롯 검사 2곳은 범위가 달라 중복 아님으로 판정(§57).
  • 8.4 test 배치: 이 sub-scope 전용 test 4개지만 계약 test는 sub-scope 04 패키지에 있음을 확인 — 확장/관리 평면 모두 계약 test 보유(§53).

60. Sub-scope 06 findings backlog

우선순위 finding reachability
P3 NOSCRIPT 복구가 5벌로 구현되고 그중 4벌이 RedisScriptRegistry의 body 대조를 받지 않는다. README는 하나의 닫힌 메커니즘으로 서술한다 Redis 오류 문자열 변경 · 스크립트 body 관리
P3/기록 raw gateway·transaction runner·admin operations·cluster observer·cluster scan cursor 다섯 진입 타입의 production 소비자 0 (내부 협력자는 배선됨) 조립 완료 시점

61. Sub-scope 06 완료 조건

  • denominator 54 / 54 FULL_READ (164-... OWNED FILES)
  • §8.1~§8.4 probe 수행, 중복 mechanism 2건 조사(하나는 중복 아님으로 판정)
  • README의 복구 사슬 주장을 호출 지점으로 추적해 실재 확인하되 구조는 다름을 기록(§55, §56)
  • 소스 미변경

62. Sub-scope 07 범위와 denominator

내부 상태: COMPLETE — 24 / 24 FULL_READ 범위: cache 2 + idempotency 2 + lease 2 + ratelimit 3 + realtime 4 + keyspace 1 (main 14, 3,295 LOC) + 전용 test 5 + live/topology 기반 5 역할: application-core·shared-contract의 의미 포트를 Redis로 구현하는 층

manifest와 probe: evidence/raw/165-cache-redis-semantic-adapters-probes.txt.

63. 여섯 개의 의미 포트가 실제로 구현돼 있다

RedisCacheRegionAdapter<K,V>        implements CacheRegionPort<K,V>
RedisIdempotencyStoreAdapter        implements IdempotencyStorePortV2
RedisDistributedLeaseAdapter        implements DistributedLeasePort
RedisEdgeRateLimitAdapter           implements EdgeRateLimitPort
RedisConnectionRegistryAdapter      implements ConnectionRegistryPort
RedisEphemeralFanoutAdapter         implements EphemeralFanoutPort

각각이 자기 포트의 실패 정책을 명시적으로 다르게 정한다. 그 대비가 이 sub-scope의 중심이다.

포트 실패 시 근거(javadoc)
cache degrade — miss 또는 DEGRADED_UNAVAILABLE "a cache exists to make things faster… That licence is specific to this port and must never be copied to session, idempotency, rate limit, or lease"
rate limit fail-closedUnavailable "a limiter that allows traffic when its store is unreachable removes the bound at exactly the moment it matters… an in-process count during a Redis outage is not a global limit, it is N times the limit"
idempotency INDETERMINATE "a caller told 'failed' retries and duplicates the effect, while a caller told 'indeterminate' inspects with the same attempt and discovers what actually happened"
connection registry "nothing found" 라우팅 힌트이므로 "Throwing would turn a Redis blip into a failed user-visible operation"
ephemeral fanout publish 실패는 오류 아님 메시지가 본래 ephemeral이라 "'the broker did not accept it' and 'it reached nobody' are the same outcome"

세부도 정직하다.

  • RedisDistributedLeaseAdapter는 이름이 계약이다 — "Efficiency only… There is no fencing token, so a holder that is paused past its expiry cannot be stopped from acting; anything correctness-sensitive needs a conditional write at the point of effect, not a lock in front of it. Saying so in the type name is the only durable way to keep the next caller from reaching for it as a mutex." 유효성은 서버 TTL이 아니라 요청을 보낸 시각부터 monotonic 시계로 재고, 왕복 시간만큼 의도적으로 비관적이다.
  • IdempotencyScripts는 owner와 revision을 함께 확인한다 — owner만 보면 만료된 보유자가 새 보유자의 작업을 덮고, revision만 보면 같은 revision의 다른 owner가 덮는다. 레코드가 문자열이 아니라 해시인 이유도 적혀 있다("a read-modify-write of a serialized blob would reintroduce exactly the race the programs remove").
  • RateLimitScripts는 서버 TIME을 쓰지 않는다 — 스크립트가 비결정적이 되고, 판정이 caller의 deadline과 같은 시계로 측정돼야 하기 때문이다. 시계 역행은 정책의 clock-regression bound로 다룬다.
  • RateLimitKeys는 정책 revision을 키에 넣는다 — 한도를 100/분에서 10/분으로 바꿨을 때 옛 카운터가 남아 있으면 이미 50을 쓴 주체가 10짜리 예산으로 계속하게 되고, 반대 방향이면 새 할당을 받는다. "A revision in the key means a policy change starts new counters, which is the only interpretation that is correct in both directions."
  • 주체·행위자는 digest로만 들어온다 — "a Redis key reaches MONITOR output, the slow log, KEYS during an incident and every backup — none of which has the access controls the application has, and all of which outlive the request."
  • RegistrationCodec가 JSON이 아닌 이유는 롤링 배포다 — 필드를 추가한 JSON 리더는 구버전 노드가 계속 쓰는 항목마다 실패하므로, 선행 버전 토큰으로 "감지하고 건너뛰기"를 가능하게 한다.
  • CapabilityKeyspace는 과거의 실제 사고를 고친 결과다 — 각 capability가 자기 순서로 토큰을 이어 붙여 ca-skeleton:prod:cache:…prod:ca-skeleton:shared:…가 공존했고, "An account restricted to ~prod:* could not touch a single cache entry, and nothing said so until a real server refused the write." 지금은 SDK와 같은 RedisNamespace.prefix()에서 시작한다.

64. P2 — 의미 어댑터 다섯이 CommandPolicyGuard를 지나지 않는다

이 leaf의 아키텍처 주장은 두 javadoc에 있다.

CommandPolicyGuard: "The single admission point every command passes through." RedisCommandGateway: "Policy, permits, budgets, timeouts, and observability are not this interface's concern: everything routed through it has already passed CommandPolicyGuard."

의미 어댑터 다섯은 그 전제를 만족하지 않는다(165-... §8.1).

  • SyncRedisCommandExecutor·ReactiveRedisCommandExecutor·CommandPolicyGuard·CommandRequest를 참조하는 파일 0(exit=1)
  • 타입 있는 API(RedisValueOperations·RedisHashOperations·RedisKeyOperations·RedisOperations)를 참조하는 파일 0(exit=1)
  • 대신 RedisRuntimeOwner(5) → RedisLease(5) → lease.gateway()를 직접 호출한다 — cache 6곳, idempotency 6곳, lease 4곳, ratelimit 1곳, realtime 13곳

즉 이 다섯 어댑터가 보내는 모든 명령에 대해 다음이 실행되지 않는다.

guard 단계 이 경로에서
카탈로그 분류(BLOCKED·R3·R4 거부) 없음
capability / 최소 버전 확인 없음
permit provenance 검증 없음
네임스페이스 검사 없음 — 다만 §63의 CapabilityKeyspace가 같은 RedisNamespace에서 키를 조립하므로 구성으로는 유지된다
Cluster 동일 슬롯 검사 없음
요청 예산 없음
정책 기반 레인·타임아웃 유도 없음 — 어댑터가 자기 commandTimeout.get(...)에 직접 적용
실패 번역(LettuceExceptionTranslator) 없음 — 어댑터가 Exception을 직접 잡아 자기 결과 타입으로 접는다
관측(RedisObservation) 없음

두 번째 결과: 키 렌더 경로가 둘이다. sub-scope 03 §22에서 확인한 주장 — "There is no API that takes an already rendered key string, so namespace, slot, and size rules cannot be bypassed" — 은 타입 있는 API에 대해서는 참이다. 그러나 CapabilityKeyspace.key(...)byte[]를 직접 만들어 gateway에 넘기고, RedisKeyRenderer를 거치지 않으므로 RedisKeyRules.requireRenderedSize(...)가 적용되지 않는다(165-... §8.2: CapabilityKeyspacerequireRenderedSize·MAX_KEY_BYTES 매치 0). 슬롯 태그 중괄호 규칙("The renderer is the only place braces are written")도 이 경로에는 없다.

판정: P2. 완화 요인이 실재한다 — (a) 현재 이 어댑터들은 bean으로 조립되지 않아 노출이 없고, (b) 키는 네임스페이스에서 조립되며, (c) 명령은 caller가 주는 것이 아니라 어댑터가 고정한 소수이고, (d) 각 어댑터가 자기 타임아웃과 실패 정책을 명시적으로 갖는다. 그래서 즉각적 데이터 위험은 없다.

위험은 구조적이다. 이 leaf 전체가 "모든 명령이 지나는 단일 입장 지점"이라는 주장 위에 서 있고, 그 주장을 강제하는 test도 없다 — RedisSdkModuleBoundaryTest가 패키지 경계를 강제하지만 "gateway를 부르는 것은 executor뿐"은 강제하지 않는다. 조립이 완료되는 시점(§5)에 이 다섯 어댑터는 카탈로그·permit·슬롯·예산·번역·관측 없이 도는 다섯 개의 경로가 된다. 특히 Cluster에서 동일 슬롯 검사 부재는 실제 실패로 이어진다 — realtime 어댑터는 세 구조(actor 해시·node 집합·heartbeat sorted set)를 함께 쓰는데 그 셋이 같은 슬롯에 있다는 보장이 코드 어디에도 없다.

수정 방향은 둘 중 하나다. 어댑터를 타입 있는 API 위로 올리거나(그러면 permit·budget 서명을 만족시켜야 한다), 최소한 SyncRedisCommandExecutor를 통과시켜 카탈로그·슬롯·번역·관측을 얻는 것. 그리고 어느 쪽이든 "gateway의 유일한 호출자는 executor다"를 강제하는 ArchUnit 규칙 하나가 이 종류의 재발을 막는다.

65. Confirmed — README의 "그 코드는 이 leaf에 없다"가 결정적으로 반증된다

sub-scope 01 §5에서 제기한 P2를 여기서 확정한다. README:35–37은 이렇게 적는다.

"아래 절들은 이전 세대 semantic adapter 세트의 설계 결정을 기록한 것이며, 그 코드는 현재 이 leaf에 없다. 복구 범위는 위 plan의 Phase E가 소유한다."

그리고 readiness 표는 "cache / session / idempotency / rate limit / lease semantic port | API 구현 없음"이다.

실제로는 application-core/shared-contract여섯 포트가 구현돼 있고(§63), 3,295 LOC이며, 각 어댑터에 전용 test가 있고(RedisCacheRegionAdapterTest 333 · RedisIdempotencyStoreAdapterTest 337 · RedisDistributedLeaseAdapterTest 292 · RedisEdgeRateLimitAdapterTest 321 · RedisConnectionRegistryAdapterTest 262), 토폴로지 lane의 LiveRedisSemanticPortsTest(364 LOC)가 실제 서버에 대해 다시 검증한다. README 자신이 §0에서 인용한 standalone lane 서술("세 rate-limit 프로그램, 각 프로그램의 exact-boundary/denial-no-consume, clock-regression state 불변, token refill remainder와 malformed hash 분류를 검증한다")도 바로 이 코드를 가리킨다 — 같은 문서 안에서 한 절은 이 코드의 검증 범위를 설명하고 다른 절은 이 코드가 없다고 말한다.

66. Negative-space probes — sub-scope 07

  • 8.1 reachability / 계층 통과: 다섯 어댑터의 guard·executor·타입 API 참조 0, gateway 직접 호출 30곳(§64).
  • 8.1b 문서화된 전제 대조: gateway와 guard의 javadoc이 주장하는 "모든 명령"이 이 경로에서 거짓임을 확인(§64).
  • 8.2 두 번째 키 렌더 경로: CapabilityKeyspacerequireRenderedSize를 적용하지 않음(§64).
  • 8.3 조건부 형제: 여섯 포트의 실패 정책이 서로 다르고 각각 근거가 적혀 있음 — cache만 degrade, rate limit은 fail-closed(§63).
  • 8.4 문서 drift 확정: README의 네 행과 "코드가 없다"는 서술을 구현·test·lane 세 겹으로 반증(§65).

67. Sub-scope 07 findings backlog

우선순위 finding reachability
P2 의미 어댑터 다섯이 CommandPolicyGuard와 executor를 지나지 않고 lease.gateway()를 직접 호출한다 — 카탈로그·permit·슬롯·예산·실패번역·관측이 모두 적용되지 않고, CapabilityKeyspace가 두 번째 키 렌더 경로로 requireRenderedSize를 건너뛴다 현재 미배선; 배선 시 다섯 경로 전부, 특히 Cluster 슬롯
§5(README drift)를 이 sub-scope의 증거로 확정 — 별도 finding으로 세지 않고 §5에 귀속

68. Sub-scope 07 완료 조건

  • denominator 24 / 24 FULL_READ (165-... OWNED FILES)
  • §8.1~§8.4 probe 수행, 조건부 형제 비교 1건(여섯 포트의 실패 정책)
  • sub-scope 01 §5의 P2를 구현·test·lane 세 겹으로 확정(§65)
  • 소스 미변경

69. 모듈 ledger 정합

# 범위 main test 기타 FULL_READ probe
1 governance + sdk/config/** 12 8 6 26 26 158, 159
2 sdk/api/operations + api 루트 + codec 78 5 83 83 160
3 sdk/api/key + error + command + reactive 61 3 64 64 161
4 sdk/lettuce/operations 67 28 95 95 162
5 sdk/lettuce/{command,connection,codec,observability} 32 11 1 44 44 163
6 programmability + extensions + raw + cluster + admin 50 4 54 54 164
7 의미 포트 어댑터 6종 + live/topology 기반 14 10 24 24 165
TOTAL 314 69 7 390 390 7 / 7

coverage ledger: FULL_READ 390 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / 미분류 0.

70. 모듈 findings

# 우선순위 finding 위치 reachability
1 P2 README readiness 표 4행 중 3행과 "그 코드는 이 leaf에 없다"가 사실과 다르다. 의미 포트 6종 3,295 LOC가 전용 test·live lane과 함께 실재하고, auto-configuration은 client·runtime owner·health contributor 2종을 실제로 만든다. build.gradle의 "0 imports across main source"도 실측 application.* 7파일 / shared.* 3파일과 어긋난다 §5, §65 이 leaf를 읽는 모든 fork
2 P2 의미 어댑터 다섯이 CommandPolicyGuard·executor·타입 API를 지나지 않고 lease.gateway()를 직접 호출한다(30곳). 카탈로그·permit·슬롯·예산·실패번역·관측 미적용, CapabilityKeyspace가 두 번째 키 렌더 경로 §64 현재 미배선; 배선 시 다섯 경로, 특히 Cluster 슬롯
3 P2 RedisStartupProbe/RedisCapabilityProbe의 production 호출자 0 — 7.2 baseline·Cluster db0·명시 capability·복제 쓰기 내구성 네 guardrail이 조립된 애플리케이션에서 실행되지 않는다 §6 Redis를 켠 모든 배포, 특히 복제 배포
4 P2 catalog drift gate(RedisCommandMetadataDiff)가 "fails the build"라고 선언돼 있으나 실제 서버 메타데이터를 넣는 실행 경로가 없다 — main 참조 0, lane 참조 0, Gradle 태스크 0 §47 key spec 이동 · ACL 카테고리 확대 · deprecation
5 P2 RedisOperations·ReactiveRedisOperations 두 진입점의 구현이 0 — 개별 표면 43종은 전부 구현돼 있다 §15 SDK를 쓰려는 모든 fork
6 P3 patternSubscribe가 R2인데 호출자 permit이 아니라 SDK 자기 발급 permit으로 배포 수준만 확인한다 §36 패턴 구독 사용 배포
7 P3 permit 정책 이름이 Java 상수 18 · YAML 18 · 별도 private 상수 1로 흩어져 있고 두 집합의 일치를 검사하는 장치가 없다 §37, §48 정책 이름 오타
8 P3 NOSCRIPT 복구가 5벌로 구현되고 그중 4벌이 RedisScriptRegistry의 body 대조를 받지 않는다 §56 Redis 오류 문자열 변경
9 P3 RedisKeyRules.requireIdentifier의 메일·전화 분기가 선행 문자 클래스 때문에 도달 불가 — test는 타입만 보므로 분기를 지워도 초록 §26 진단 메시지
10 P3 PubSubChannel·ShardedPubSubChannel이 렌더 크기 검증을 받지 않는다(형제 PubSubPattern은 받는다) §16 긴 namespace/entity/identifier
11 P3 hyperLogLogs.merge·count가 다중 키 fan-in인데 OperationBudget을 받지 않는다(형제 3종은 받는다) §17 많은 레지스터 병합
12 P3/기록 ConfiguredRedisPolicyAuthority·ConfiguredRedisPermitVerifier production 생성 0 §7 gateway 조립 시점
13 P3/기록 RedisRiskLevel.requiresPermit() 호출자 0 (형제 deniedToApplications()는 2곳) §27 규칙 강제 위치 분산
14 P3/기록 RedisOperationException.metadatatransient라 직렬화 왕복 후 metadata()가 null이고 문서에 없다 §27 예외 원격 전달
15 P3/기록 TypedRedisKeys의 slot-tag 편의 생성자가 value·set 두 종뿐 §27 Cluster 다중 키
16 P3/기록 정책 문서의 필드 표가 11개를 적지만 loader는 optional-block을 포함해 12개를 받고 그 필드가 실제로 쓰인다 §48 문서
17 P3/기록 production 소비자 0인 타입 8종 — RedisCodecRegistry·SentinelFailoverObserver·LettuceRedisRawGateway·RedisTransactionRunner·LettuceRedisAdminOperations·ClusterTopologyObserver·ClusterScanCursor·RedisCommandMetadataDiff §49, §58 조립 완료 시점

결함 아님으로 판정한 후보 4건 — raw allowlist 기본 리소스 부재(§3: startup 실패로 닫혀 있음), reactive getRange 누락 의심(§25: 오탐), InMemoryRedisCommandGateway 메서드 누락 의심(§38: implements가 강제), 슬롯 검사 2곳(§57: 범위가 다름).

71. 이 모듈에서 반복해서 나타난 패턴

  • 표현 불가능성이 검증을 대체한다. TTL 없는 쓰기는 permit 없이 타입으로 만들 수 없고(Expiration.Persistent), KEYS는 API에도 카탈로그에도 없으며(R4 BLOCKED), 렌더된 키를 받는 API가 존재하지 않고, 명령 이름을 받는 gateway 메서드가 없다.
  • 정책이 코드가 아니라 데이터이고, 그 데이터의 파서가 좁다. 1,406줄 명령 카탈로그를 일반 YAML 엔진으로 읽지 않는 이유가 "anchors, merges, nested structures, and duplicate keys… could hide a risk downgrade inside a security policy file"로 적혀 있다.
  • 주석이 사고 기록이다. "the Sentinel lane measured eleven seconds and 2,086 acknowledged-then-discarded writes", "-Predis.topology.mode=TYPO built the tag lane-typo, matched nothing, ran zero tests and exited 0", "a real server returned 501 for exactly that request", "An account restricted to ~prod:* could not touch a single cache entry". 각 방어에 그것이 막는 과거 실패가 붙어 있다.
  • 죽은 중복을 지운 기록까지 남긴다. CommandPolicyGuardvalidateReply 제거 주석 — "Two mechanisms for one rule, with the more visible one dead, is worse than one."
  • 그리고 다섯 개의 P2는 전부 같은 모양이다선언과 조립의 간극. 문서가 없다고 말하는 코드가 있고(1), 모든 명령이 지난다는 입장 지점을 지나지 않는 경로가 있으며(2), startup에 한 번 묻는다는 probe가 불리지 않고(3), 빌드를 깬다는 gate가 실행되지 않으며(4), 진입점이라고 선언된 타입에 구현이 없다(5). 개별 구성요소의 품질(설계·주석·test)과 그것들을 하나로 잇는 배선 사이에 일관된 격차가 있고, 이 모듈에서는 그 격차가 앞선 아홉 모듈보다 크다.

72. 모듈 완료 조건

  • denominator 390 / 390 FULL_READ, STRUCTURAL_ONLY 0, EXCLUDED 0, 미분류 0 (§69)
  • 7개 하위 범위 전부 §8.1~§8.4 네 종 negative-space probe 수행, evidence 158~166 9건 생성
  • 후보 finding 4건을 코드로 추적해 결함 아님으로 판정, sub-scope 04의 이월 1건을 sub-scope 05에서 확정
  • 소스 미변경 — 이 분석은 어떤 애플리케이션 코드도 수정하지 않았다

73. 검증

evidence/raw/166-cache-redis-suite-verification.txt.

$ cd src && LANG=C.UTF-8 LC_ALL=C.UTF-8 ./gradlew :adapter:outbound:cache-redis:test --console=plain -q
GRADLE_EXIT=0
classes=52 tests=435 failures=0 errors=0 skipped=0

$ git status --short
changed=0

skip 0이다. redisTopologyTest lane 4종(standalone·sentinel·cluster·tls)은 :test에서 태그로 제외되며(§0), 각 lane은 실제 서버와 -Predis.topology.* 프로퍼티를 요구하므로 이 분석에서 실행하지 않았다. lane 자신의 fail-closed 장치들(mode allowlist·필수 클래스·최소 test 수·skip 0)은 정적으로 확인했다.

작업 트리는 변경 0이다 — 이 분석 과정에서 애플리케이션 소스를 수정하거나 임시 파일을 남기지 않았다.

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 125개 (main 106 · test 18 · 기타 1).

src/adapter/outbound/cache-redis/build.gradle
src/config/architecture/modules.json                    (adapter-outbound-cache-redis 항목)

main:
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/RedisIdempotencyStoreAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/keyspace/CapabilityKeyspace.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitKeys.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RegistrationCodec.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/admin/LettuceRedisAdminOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/ReactiveRedisOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/RedisOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/AdvancedOperationPermit.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/CommandId.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/KeySpec.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/MultiKeyPermit.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/OperationBudget.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/PersistentKeyPermit.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/RedisCommandDescriptor.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/RedisPermitVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/command/RedisRiskLevel.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/error/RedisCrossSlotException.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/error/RedisFailureMetadata.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/error/RedisNoScriptException.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/error/RedisOperationException.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/BitmapKey.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/GeoKey.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/HashKey.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/HyperLogLogKey.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/ListKey.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/QualifiedRedisKey.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyName.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRenderer.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRules.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisNamespace.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisSlotTag.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisTypedKey.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/SetKey.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/SortedSetKey.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/StreamKey.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/TypedRedisKeys.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/ValueKey.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/BatchItemResult.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/BitFieldSubcommand.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/Expiration.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/GeoPoint.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/GeoSearchRequest.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/PubSubChannel.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/PubSubPattern.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/RedisHashOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/RedisKeyOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/RedisSetOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/RedisValueOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/ShardedPubSubChannel.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/SortDirection.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/operations/SortedSetAddOptions.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/reactive/ReactiveRedisValueOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/cluster/ClusterScanCursor.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/cluster/ClusterTopologyObserver.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/cluster/RedisSlotCalculator.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/cluster/SameSlotValidator.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/ConfiguredRedisPermitVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/ConfiguredRedisPolicyAuthority.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisCapabilityProbe.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisCorrectnessRoles.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisCredentialResolver.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisHealthContributor.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisStartupProbe.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/extensions/ExtensionCommandRunner.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/extensions/search/LettuceRedisSearchOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/codec/RedisCodecRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/CommandPolicyGuard.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/CommandRequest.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/LettuceExceptionTranslator.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/ReactiveRedisCommandExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisCommandCatalog.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisCommandMetadataDiff.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisCommandPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisCommandPolicyLoader.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisServerCommandMetadata.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/SyncRedisCommandExecutor.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisConnectionKind.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisConnectionRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisLease.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwner.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisTopologyClientFactory.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/SentinelFailoverObserver.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/observability/NoThrowObservationSink.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/observability/RedisObservation.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LettuceReactiveRedisValueOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LettuceRedisCommandGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LettuceRedisHashOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LettuceRedisValueOperations.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/PubSubOperationRequests.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisCommandGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisOperationContext.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/ValueOperationRequests.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/programmability/RedisScriptRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/programmability/RedisTransactionRunner.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/raw/ApprovedRawCommand.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/raw/LettuceRedisRawGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/raw/RawCommandApprovals.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/raw/RawMovableKeys.java
  src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/raw/RedisRawGateway.java
  src/main/resources/redis-sdk/redis-command-policy.yml

test:
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/RedisIdempotencyStoreAdapterTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapterTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RedisEdgeRateLimitAdapterTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RedisConnectionRegistryAdapterTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisSdkModuleBoundaryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/ApiParityTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisCapabilityProbeTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisStartupProbeTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/codec/RedisCodecRegistryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisCommandPolicyLoaderTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/InMemoryRedisCommandGateway.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisAdminPlaneContractTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisExtensionModulesContractTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisTransactionContractTest.java
  src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisTransactionSlotContractTest.java
  src/test/resources/redis-sdk/golden/order-summary-v1.json

기타:
  src/build.gradle

해석되지 않은 인용 (10종) — 외부 타입·문서상 약칭 등:
  evidence/raw/158-cache-redis-module-inventory.txt
  evidence/raw/159-cache-redis-config-activation-probes.txt
  classpath:redis-sdk/raw-command-allowlist.yml
  evidence/raw/160-cache-redis-api-surface-probes.txt
  evidence/raw/161-cache-redis-key-error-command-probes.txt
  evidence/raw/162-cache-redis-gateway-probes.txt
  evidence/raw/163-cache-redis-guard-connection-codec-probes.txt
  evidence/raw/164-cache-redis-programmability-extensions-probes.txt
  evidence/raw/165-cache-redis-semantic-adapters-probes.txt
  evidence/raw/166-cache-redis-suite-verification.txt


A11. adapter-outbound-httpclient

분석 중에는 11-adapter-outbound-httpclient.md 파일이었다. 837줄.

11 · adapter-outbound-httpclient 완전 해부

상태: COMPLETE — 2026-08-31 재검증 기준 revision(최초 분석): a24ece9cf797f7ea647e33bf846b115208ed1ba5 재검증 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 · 리프 소스 변경 0 (EVD-333) 분석 범위: src/adapter/outbound/httpclient · Gradle :adapter:outbound:httpclient SSOT owner: adapter-outbound-httpclient integration/family document: §A00 (secondary, INTEGRATION_ONLY)


0. SSOT identity · denominator · coverage ledger

  • registered leaf id: adapter-outbound-httpclient
  • canonical state analysisFile: §A11 (이 문서)
  • source path: src/adapter/outbound/httpclient
  • registry allowed_dependencies: ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"]
  • registry runtime_memberships: ["app-bootstrap"]
  • 재검증 증거: EVD-332(§51 진단 교정), EVD-333(소스 드리프트 0)

고정 섹션 골격과의 대응. 이 문서는 leaf 안에 7개 하위 범위가 있어 하위 범위별로 번호를 이어 쓴다. 고정 골격의 각 역할은 다음 절이 맡는다 — 재검증은 번호를 바꾸는 대신 대응을 명시한다.

고정 골격 이 문서
§0 SSOT identity / denominator / coverage ledger 이 절 + §52
하위 범위 denominator §1, §10, §19, §27, §34, §41, §45
§12 negative-space probes 4종 §7, §16, §24, §31, §38, §48 (하위 범위별)
§14 evidence table §55 + EVD-332, EVD-333, evidence 167~175
§16 확인한 것 / 확인하지 못한 것 §55
§17 손볼 것 (P1/P2/P3) + 확인된 설계 §53

tracked file 370개 — main 260 (15,004 LOC), test 62 (6,049), testkit source set 35 (2,754), httpClientPerformanceTest 7 (495), jmh 2 (130), governance 4. 총 약 24.4k LOC.

{ "id": "adapter-outbound-httpclient",
  "gradle_path": ":adapter:outbound:httpclient",
  "allowed_dependencies": ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"],
  "runtime_memberships": ["app-bootstrap"] }

cache-redis와 같은 형태다 — 설계 문서는 이 플랫폼을 19개 Gradle 모듈로 나누지만 "This repository's fail-closed module registry outranks that layout, so the module boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and HttpClientModuleBoundaryTest enforces the design's module dependency table."

main 패키지 배치(260):

계층 패키지 파일
복원력 resilience 39
프로파일/조립 profile 29
공개 API api/error 23 · api/result 9 · api/body 7 · api/operation 6 · api 5 50
보안 security 21 · auth 18 39
게이트웨이 restclient 19 · webclient 17 36
서비스/동적 service 14 · dynamic 11 · observation 6 · migration 4 35
전송 transport 10 · reactor 6 · apache 5 · jdk 4 · http3 4 · spring7 3 32

build.gradle이 이 저장소에서 가장 긴 근거 주석을 갖는다. 몇 가지가 특히 이 leaf의 성격을 보여 준다.

  • HTTP/3가 compileOnly인 이유 — 이전에는 implementation이어서 "the whole QUIC/HTTP-3/QPACK stack on every deployment's runtime classpath — megabytes and an attack surface — to serve a feature the Stable starter never auto-configures."
  • Jackson 3가 optional이 아닌 이유RestClientResponseReader.defaultConverters()가 Spring 7 컨버터를 만드는데 생성자 서명에 타입이 없어 javac가 못 잡았고, Jackson 3가 test/jmh 클래스패스에만 있어서 "the reader worked in every test and would have thrown NoClassDefFoundError in any deployment that did not happen to have Jackson 3 from somewhere else."
  • Resilience4j는 실행 원시연산만 — "HTTP retry eligibility is owned by this module (design D-09) and never delegated to a generic retry library."
  • testkit이 별도 source set인 이유 — 세 lane이 소비하는데 그중 하나만 test lane이고, jmhsourceSets.test.output을 참조하면 IDE가 모델링하지 못해 "every testkit reference in the benchmarks was an unresolved type in the editor while the build was green."
  • lane 5개 중 둘이 failOnNoDiscoveredTests를 잃었던 기록 — "the cross-transport contract suite and the SSRF/credential-leak suite would each have reported success on discovering nothing. Declaring the lanes removes the opportunity: the convention has no opt-out."
  • check가 무엇을 게이트하는지 — 특수 lane들이 "existed but hung off nothing: check ran only test". 지금은 hermetic한 넷(contract·security·BlockHound·spring62 surface)이 check에 붙어 있고, Docker가 필요한 fault lane과 머신 의존적인 performance lane은 의도적으로 빠져 있다.
하위 범위 ledger
# 범위 main test 기타 상태
1 governance + profile/** — 프로파일 검증 · 런타임 레지스트리 · 조립 29 2 4 35 COMPLETE
2 api/** — error · result · body · operation · 루트 50 5 55 COMPLETE
3 resilience/** — 재시도 자격 · 회로 · 격벽 · 파이프라인 39 8 47 COMPLETE
4 restclient/** + webclient/** — 블로킹/리액티브 게이트웨이 36 10 46 COMPLETE
5 security/** + auth/** — SSRF 방어 · TLS 재료 · 자격증명 39 7 46 COMPLETE
6 service + dynamic + observation + migration + contract/architecture test 35 21 56 COMPLETE
7 전송 6종 + testkit / performance / jmh source set 32 9 44 85 COMPLETE
TOTAL 260 62 48 370 7 / 7

manifest: evidence/raw/167-httpclient-module-inventory.txt.


1. Sub-scope 01 범위와 denominator

내부 상태: COMPLETE — 35 / 35 FULL_READ 범위: governance 4 + profile/** main 29 (1,271 LOC) + 전용 test 2 역할: Named Client Profile의 fail-closed startup 검증과 런타임 세대(generation) 레지스트리

manifest와 probe: evidence/raw/168-httpclient-profile-probes.txt.

2. ClientProfileValidator — 34개 위반 코드가 각각 과거 사고를 적는다

이 저장소에서 본 가장 조밀한 설정 검증기다. validate(profile, environment)가 11개 검사 그룹을 돌리고 결과를 정렬해 "a configuration error reports deterministically across runs and machines"를 보장한다.

특히 이 leaf에서만 보이는 태도가 하나 있다 — 바인딩은 되지만 어떤 전송에도 닿지 않는 설정을 무시하지 않고 거부한다.

"Three of them had no consumer anywhere: timeout.dns, proxy.credential-provider and proxy.import-ambient-no-proxy. An operator who set a DNS timeout believed resolution was bounded and it was not; one who named a proxy credential provider believed the proxy was authenticated and it was not… the honest position is to refuse a value the platform cannot honour instead of accepting it and doing nothing."

기본값은 통과시키므로 "only a deliberate, unmet request fails"다. 앞선 열 개 모듈에서 반복해 발견한 "선언되었으나 아무것도 하지 않는 설정" 패턴을, 이 모듈은 명시적 거부로 처리한다.

같은 논리가 관측 설정에도 적용된다 — full-url-recording은 아무도 읽지 않았고 body-logging은 actuator 보고에만 닿았다. "Leaving them that way is the worse of the two failure modes — an operator who set them believed the platform was recording full URLs or bodies, and an operator who left them false had no assurance that it was not." 지금은 production에서 둘 다 거부된다.

나머지 검사도 각각 구체적인 다운그레이드를 막는다.

  • REACTIVE_REDIRECT_UNSUPPORTED — 엔진 리다이렉트는 모든 전송에서 꺼져 있고 hop별 재검증을 하는 coordinator는 블로킹 스택에만 있다. 리액티브 프로파일이 redirect를 켜면 "the caller received the 302 as an ordinary response and read its empty body as the answer." 거부가 정직한 결과다 — "a configured guarantee that silently does nothing is worse than one the platform declines to offer."
  • HTTP2_REQUIRED_TRANSPORT_UNSUPPORTEDProtocolIntent가 "H2를 선호"와 "H2를 요구"를 구분한다. JDK 클라이언트는 HTTP_2를 선호로 다뤄 조용히 HTTP/1.1로 협상하고 Apache classic은 HTTP/1.1 전용이라, HTTP_2만 선언한 프로파일이 "ran happily over HTTP/1.1, and nothing anywhere said so."
  • TLS_PROTOCOL_SET_REQUIRED — 빈 집합이 통과하면 JVM 기본값이 선택되어, "a profile that meant to pin a TLS floor got whatever the platform default happened to be — including TLS 1.2 on a profile whose operator had deliberately emptied the list to 'tighten' it."
  • DYNAMIC_TARGET_PROXY_UNSUPPORTED — 포워드 프록시는 호스트명을 자기 쪽에서 다시 해석하므로 "The SSRF defence would be present, correct, and bypassed."
  • RETRY_POLICY_CONTRADICTS_ATTEMPTSpolicy를 실행 경로에서 아무도 읽지 않아 "the actuator could report retryPolicy: none for a profile that was retrying three times."

배선 확인. app-bootstrapHttpClientStartupValidator:37이 이 검증기를 생성한다(168-... §8.1). 이 leaf는 앞선 cache-redis와 달리 실제로 조립돼 있다 — app-bootstrap에 이 leaf를 위한 auto-configuration 12개가 있다.

3. ClientRuntimeRegistry — 세대 교체가 틈으로 관측되지 않는다

"A swap publishes the replacement first and drains the predecessor afterwards, so a rotation is never observable as a gap." acquire는 관측한 세대가 예약 직전에 draining으로 넘어가면 새로 발행된 세대에 대해 재시도한다.

과거 누수 두 건이 코드와 주석에 남아 있다.

  • 교체된 세대가 runtimes에서 빠지고 스케줄된 drain 작업만 소유하게 되어, 그 작업이 발화하기 전에 레지스트리가 닫히면 "leaked the whole generation — and the resource-bound suite could not see it, because nothing enumerated it." 지금은 retired 집합이 추적한다.
  • close()forEach로 닫다가 첫 예외에서 멈춰 "a single misbehaving pool left every remaining connection, thread and socket open — shutdown leaked more the worse the failure was." 지금은 전부 닫고 실패를 suppressed로 모은다.

4. P3 — close()가 실패하면 drain 스케줄러 스레드가 남는다

§3의 두 번째 수정이 절반만 적용돼 있다.

    retired.clear();
    runtimes.clear();
    if (firstFailure != null) {
      throw firstFailure;                                    // ← 여기서 던진다
    }
    ScheduledExecutorService scheduler = drainScheduler.getAndSet(null);   // ← 도달하지 않는다
    if (scheduler != null) {
      // Await termination: a registry that returns while its drain thread is still alive would
      // leak a thread per rotation cycle, which the resource-bound suite exists to catch.
      scheduler.shutdownNow();
      
    }

forceClose() 중 하나라도 던지면 throw firstFailure가 먼저 실행되어 스케줄러 종료 블록에 도달하지 않는다. drainScheduler도 비워지지 않으므로 스레드가 살아 있고, 클래스 javadoc이 "The single scheduled executor… is shut down with the registry so no thread outlives it"이라고 적은 성질이 그 경로에서 성립하지 않는다.

바로 위 루프는 "Every runtime is closed even when one refuses"를 위해 예외를 모으도록 고쳐졌는데, 같은 논리가 스케줄러에는 적용되지 않았다. test closingTheRegistryReleasesEveryGenerationAndLeavesNoThread는 실패 없는 경로만 검증한다.

판정: P3. 스레드가 데몬이라 JVM 종료를 막지는 않고, 레지스트리당 하나이며, 닫기 실패라는 조건이 필요하다. 그러나 주석이 "a thread per rotation cycle"을 명시적 위험으로 적고 resource-bound suite가 그것을 잡으려 존재하는데, 정확히 그 누수가 실패 경로에 남아 있다. 수정은 스케줄러 종료를 finally로 옮기는 한 줄이다.

5. P3 — POOL_ROUTE_EXCEEDS_TOTAL 위반 코드는 발화할 수 없다

// PoolSettings 정규 생성자
if (maxConnectionsPerRoute > maxTotalConnections) {
  throw new IllegalArgumentException("per-route pool must not exceed the total pool");
}

// ClientProfileValidator:192
if (profile.pool().maxConnectionsPerRoute() > profile.pool().maxTotalConnections()) {
  out.add(violation("POOL_ROUTE_EXCEEDS_TOTAL", profile, "pool.max-connections-per-route"));
}

ClientProfile은 이미 구성된 PoolSettings를 들고 있고, 그 record는 route > total인 상태로 존재할 수 없다. production의 유일한 생성 지점(HttpClientProfileFactory:83)도 같은 생성자를 지난다. 따라서 검증기의 이 분기는 도달 불가이고, 그 코드의 test 참조가 0인 것도 그래서다(§6).

sub-scope 03(redis)의 requireIdentifier 죽은 분기와 같은 모양이다 — 선행 검증이 후행 검증을 가린다. 다만 결과가 다르다: record 생성자는 IllegalArgumentException을 던져 startup을 즉시 실패시키므로, 검증기가 수집해 정렬된 목록으로 보고하는 결정적 진단 형식을 이 한 조합만 받지 못한다. 주석이 설명하는 실제 위험("on Reactor — where the per-route knob is the only one that exists — it silently becomes the effective limit")은 여전히 막혀 있다. P3.

6. P3 — 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않는다

ClientProfileValidator가 내는 코드는 34종이다. 저장소 전체의 test/testkit source set에서 그 문자열을 참조하는 파일 수를 세면(168-... §8.4b):

test 참조 코드 수
1건 이상 12 TRUST_ALL_FORBIDDEN(3) · HOSTNAME_VERIFICATION_REQUIRED(2) · PLAINTEXT_*(2) · HTTP3_STABLE_FORBIDDEN(2) …
0건 22 DYNAMIC_TARGET_PROXY_UNSUPPORTED · FULL_URL_RECORDING_FORBIDDEN · BODY_LOGGING_FORBIDDEN · REACTIVE_REDIRECT_UNSUPPORTED · HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED · TLS_PROTOCOL_SET_REQUIRED · DNS_TIMEOUT_UNSUPPORTED · PROXY_CREDENTIAL_UNSUPPORTED · RETRY_POLICY_CONTRADICTS_ATTEMPTS · MISSING_PRODUCTION_SETTING · ALLOWED_HOST_MISMATCH · ALLOWED_PORT_MISMATCH

문제는 개수가 아니라 어느 쪽이 비어 있는가다. 확인되지 않는 22종에는 §2가 인용한 사고 유래 가드가 거의 전부 들어 있다 — SSRF 우회(DYNAMIC_TARGET_PROXY_UNSUPPORTED), 로그의 PII(FULL_URL_RECORDING_FORBIDDEN·BODY_LOGGING_FORBIDDEN), 조용한 프로토콜 다운그레이드(HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED·TLS_PROTOCOL_SET_REQUIRED), 아무 일도 하지 않는 설정(DNS_TIMEOUT_UNSUPPORTED·PROXY_CREDENTIAL_UNSUPPORTED), 그리고 리다이렉트를 조용히 무시하는 경우(REACTIVE_REDIRECT_UNSUPPORTED).

test 두 개(ClientProfileValidatorTest 106줄)가 그룹으로 몇 개를 묶어 확인하지만(rejectsSimpleFactoryAndUnacknowledgedHttp3AndJdkRoutePool), 나머지 22종은 분기를 지워도 초록으로 남는다. 코드 자체는 현재 옳다 — 위험은 회귀다. P3. 수정은 @ParameterizedTest로 코드별 최소 케이스를 한 벌 놓는 것이고, 34종이 모두 결정적으로 정렬된 목록을 내므로 그 형태가 자연스럽다.

7. Negative-space probes — sub-scope 01

  • 8.1 reachability: ClientProfileValidator가 app-bootstrap HttpClientStartupValidator에 배선됨 확인. ClientRuntimeRegistry는 leaf 내부 10곳 + app-bootstrap 5곳에서 소비.
  • 8.2 계약 ↔ 구현: 위반 코드 34종 전수 열거와 각 코드가 막는 다운그레이드를 주석에서 추적(§2).
  • 8.3 중복 mechanism: PoolSettings 생성자와 validator가 같은 규칙을 두 번 검사하고 후자가 도달 불가(§5).
  • 8.4 test 대비 표면: 코드 34 vs test 참조 12(§6). close() 실패 경로의 스케줄러 도달성(§4).

8. Sub-scope 01 findings backlog

우선순위 finding reachability
P3 ClientRuntimeRegistry.close()throw firstFailure를 스케줄러 종료보다 먼저 실행해, 닫기 실패 시 drain 스레드가 남는다 — 클래스 javadoc의 "no thread outlives it"과 어긋난다 forceClose()가 던지는 종료
P3 POOL_ROUTE_EXCEEDS_TOTAL 분기가 PoolSettings 생성자에 가려 도달 불가 진단 형식
P3 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않고, 그 22종에 사고 유래 보안 가드가 대부분 포함된다 회귀

9. Sub-scope 01 완료 조건

  • denominator 35 / 35 FULL_READ (168-... OWNED FILES)
  • §8.1~§8.4 probe 수행, 중복 mechanism 1건 · 도달성 2건 조사
  • 위반 코드 34종을 전수 열거해 test 참조 수를 계수(§6)
  • 소스 미변경

10. Sub-scope 02 범위와 denominator

내부 상태: COMPLETE — 55 / 55 FULL_READ 범위: api/error 23 + api/result 9 + api/body 7 + api/operation 6 + api 루트 5 (main 50, 1,552 LOC) + 전용 test 5 역할: 이 플랫폼의 공개 어휘 — 무엇을 요청하고, 무엇을 증거로 삼고, 무엇이 실패했는지

manifest와 probe: evidence/raw/169-httpclient-api-probes.txt.

11. 증거(evidence) 모델이 이 모듈의 중심이다

세 개의 enum이 재시도 안전성 판단의 전부를 담는다.

ExecutionEvidenceNOT_SENT · SENT_NO_RESPONSE · RESPONSE_RECEIVED · PARTIAL_RESPONSE. 규칙 한 줄이 붙어 있다 — "NOT_SENT is only used when a stage failure proves the request never reached the server. A generic engine I/O failure is never upgraded to NOT_SENT."

AttemptStage — 12단계에 명시적 orderprovesNotSent 플래그가 붙는다. VALIDATION(0)부터 PROXY_CONNECT(6)까지가 provesNotSent=true이고 REQUEST_HEADERS(7)부터는 false다. 즉 요청 헤더를 쓰기 시작한 순간부터는 "보내지 않았다"를 증명할 수 없다는 규칙이 데이터로 표현된다. order가 enum 서수와 분리된 이유도 적혀 있다 — "the progress tracker forbids regression and the evidence classifier reads the rank, so neither depends on enum declaration ordinals."

BodyReplayabilityREPLAYABLE(3) · REOPENABLE(2) · ONE_SHOT(1) · UNKNOWN(0)weakest(left, right) 결합 연산이 있어 복합 본문이 가장 약한 쪽을 따른다. strength도 서수와 분리돼 있다.

그리고 OperationIdempotency가 네 번째 축을 더한다 — 규칙 한 줄이 설계 결정 D-09다: "Retry eligibility never derives safety from the HTTP method alone." CONTRACT_IDEMPOTENT는 "메서드는 표준 멱등이 아니지만 업스트림 계약이 반복 안전을 보장한다"를 표현 가능하게 만든다.

12. 저카디널리티·무비밀 원칙이 타입 수준에서 강제된다

HttpFailureMetadata의 javadoc이 제외 목록을 열거한다 — "Full URL, query values, expanded path variables, request or response bodies, Authorization / Cookie / API key values, the raw idempotency key, client secrets, and resolved IPs are deliberately absent."

그리고 toString() 재정의 세 곳이 각각 과거 유출을 적는다.

  • HttpOperation.toString() — 생성된 record toString이 "every header value, the body object and the expanded URI variables"를 찍었고, "an Authorization header, a request payload and a customer identifier were one stack trace away from the log aggregator." 지금은 헤더 키 집합과 본문 클래스 이름만 낸다.
  • ObjectBody.toString() — "the generated toString rendered the payload itself… which for an outbound call is by definition someone else's data." 지금은 ObjectBody[Type, media/type, REDACTED].
  • IdempotencyKey.toString()IdempotencyKey[REDACTED], 원값은 명시적 접근자로만.

api/body의 나머지 넷(BodySource·EmptyBody·IOSupplier·OneShotStreamBody·ReopenableStreamBody)은 toString을 재정의하지 않는데, 그 record 성분이 스트림 핸들·공급자·길이·미디어 타입이라 기본 toString이 데이터를 찍지 않는다. 값을 들고 있는 둘(ByteArrayBody·ObjectBody)만 재정의돼 있다 — 전수 확인했다(169-... §8.1).

HttpOperationURI 템플릿만 들고 다닌다("never an expanded URL: observability tags and failure metadata must stay low-cardinality, and the security layer expands components itself"). TRACE는 enum에 없고, test exposesHttpMethodSemanticsWithoutTraceHttpMethod.values()"TRACE"가 없음을 반사로 확인한다 — 저장소 전체에서 TRACE 문자열의 다른 참조는 그 test 한 줄뿐이다.

FailureCategory 24종이 전송 중립 어휘이고 "Every transport classifier maps engine-specific exceptions onto exactly one of these values so Apache, JDK, Reactor Netty, and Jetty produce identical retry and observation semantics." permanent()가 8종을 영구 실패로 분류한다. api/error에는 예외 21종 + 메타데이터 1종이 있고 전부 HttpClientException을 상속하며 metadata를 노출한다(test everyStableFailureExposesMetadata).

13. ObjectBody의 재생 가능성 판정 — 값의 성질이지 코덱의 성질이 아니다

이 sub-scope에서 가장 신중한 코드다. 과거 동작과 그 결과가 적혀 있다.

"Every ObjectBody used to report REPLAYABLE unconditionally, on the strength of a javadoc line asking callers not to mutate the value afterwards. A mutable DTO handed to the platform and then changed by the caller — a builder reused across calls, a collection the caller kept a reference to — produced a retry that sent different bytes under the same idempotency key, which is the one thing a replay must never do."

지금은 deeplyImmutable(value)가 구조적으로 판정한다 — 문자열·숫자·불리언·문자·enum·UUID·Temporal은 통과, 컬렉션과 맵은 JDK의 불변 뷰인지 이름으로 확인하고 원소까지 재귀, record는 모든 성분을 반사로 재귀 확인, 그 외는 전부 ONE_SHOT. 반사가 실패하면 "A component the platform cannot inspect cannot be certified, and an uncertified body is one-shot rather than optimistically replayable."

컬렉션 판정이 이름 기반인 이유도 적혀 있다 — "List.of(...) and Collections.unmodifiableList(...) return package-private classes with no shared marker interface. An ordinary ArrayList the caller still holds is exactly the case this must not accept."

test 넷이 네 갈래를 고정한다 — anImmutableRecordReplays, aMutableValueIsOneShot, aRecordWrappingMutableStateIsOneShot, anArbitraryBeanIsOneShot.

14. P3 — Number가 허용 목록에 있어 가변 숫자 타입이 REPLAYABLE로 인증된다

deeplyImmutable의 첫 분기가 candidate instanceof Number를 무조건 통과시킨다.

if (candidate instanceof String
    || candidate instanceof Number      // ← java.util.concurrent.atomic.* 가 전부 여기 들어온다
    || candidate instanceof Boolean
    ) {
  return true;
}

java.util.concurrent.atomicAtomicInteger·AtomicLong·LongAdder·DoubleAdder·LongAccumulator·DoubleAccumulator는 모두 Number를 상속하는 가변 타입이다. 따라서 성분에 AtomicInteger를 가진 record(또는 ObjectBody의 값 자체가 AtomicInteger인 경우)는 REPLAYABLE로 인증되고, 호출자가 그 사이에 값을 증가시키면 재시도가 같은 idempotency key로 다른 바이트를 보낸다 — 이 검사가 존재하는 이유로 인용된 바로 그 결과다.

같은 형태의 좁은 구멍이 Temporal에도 있다(java.time 구현체는 불변이지만 사용자 정의 Temporal 구현은 그렇지 않을 수 있다). 그러나 Number가 훨씬 현실적이다 — DTO에 카운터를 두는 것은 드물지 않다.

판정: P3. 도달성이 좁고(원자 카운터를 요청 DTO에 넣어야 한다), 검사 전체의 방향은 보수적이며, aMutableValueIsOneShot test가 일반적인 가변 객체는 잡는다. 기록하는 이유는 이 검사가 "records, enums, strings, boxed primitives and immutable collection views replay; anything else is treated as one-shot"라고 선언하는데 Number 한 줄이 그 선언보다 넓기 때문이다. 수정은 boxed primitive 여덟 종과 BigInteger/BigDecimal을 명시하거나, java.util.concurrent.atomic 패키지를 제외하는 것이다.

15. P3/기록 — 재생 가능성 판정이 호출마다 반사로 재계산된다

replayability()는 캐시 없이 매번 deeplyImmutable(value)를 돌리고, record 성분마다 accessor.setAccessible(true) + invoke를 수행한다. 재시도 엔진은 물리 시도마다 이 값을 읽으므로, 중첩이 깊은 DTO에서는 시도 수 × 트리 크기만큼 반사 호출이 일어난다.

정확성 문제는 아니다 — 결과가 값에만 의존하므로 캐시해도 같다. 이 모듈에 httpClientPerformanceTest source set이 따로 있고 "assert on resource bounds rather than behaviour"를 목적으로 하는데, 그 lane이 이 경로를 재는지는 sub-scope 07에서 확인한다. P3/기록.

16. Negative-space probes — sub-scope 02

  • 8.1 값 유출 표면: api/body의 7종 중 값을 들고 있는 둘만 toString을 재정의했고 나머지는 기본 toString이 데이터를 찍지 않음을 전수 확인(§12).
  • 8.2 허용 목록의 경계: deeplyImmutable의 7개 instanceof 분기와 컬렉션 이름 판정을 전수 검토, Number에서 구멍 발견(§14).
  • 8.3 어휘 ↔ 예외 대응: FailureCategory 24종과 api/error 예외 21종의 대응 확인.
  • 8.4 부재 주장: TRACE가 enum·구현·부트스트랩 어디에도 없고 참조는 그 부재를 확인하는 test 한 줄뿐(§12).

17. Sub-scope 02 findings backlog

우선순위 finding reachability
P3 ObjectBody.deeplyImmutableinstanceof NumberAtomicInteger·LongAdder 등 가변 숫자 타입을 REPLAYABLE로 인증한다 — 재시도가 같은 idempotency key로 다른 바이트를 보낼 수 있다 원자 카운터를 담은 요청 DTO
P3/기록 replayability()가 호출마다 반사로 재계산되고 캐시가 없다 깊은 DTO · 다중 시도

18. Sub-scope 02 완료 조건

  • denominator 55 / 55 FULL_READ (169-... OWNED FILES)
  • §8.1~§8.4 probe 수행, 허용 목록 경계 조사 1건
  • 증거 3축(ExecutionEvidence·AttemptStage·BodyReplayability)과 멱등성 축의 관계를 코드로 추적
  • 소스 미변경

19. Sub-scope 03 범위와 denominator

내부 상태: COMPLETE — 47 / 47 FULL_READ 범위: resilience/** main 39 (1,746 LOC) + 전용 test 8 (47 test 메서드) 역할: 이 플랫폼이 존재하는 이유 — 증거 기반 재시도 자격 판정과 시도별 가드

manifest와 probe: evidence/raw/170-httpclient-resilience-probes.txt.

20. 재시도 결정표가 순서로 표현돼 있다

DefaultRetryEligibilityEngine.decide의 javadoc이 규칙이다 — "The order is the point. Cheap absolute blockers come first (attempts, budget, replayability, first byte, deadline, draining), then ambiguity, then status- and failure-specific rules. A later rule can never re-enable something an earlier rule forbade."

절대 차단 여섯이 먼저다 — 시도 수 소진 · 예산 소진 · 본문 재생 불가 · 첫 바이트 전달됨 · 런타임 draining · 남은 deadline이 최소 시도 예산 이하. 그다음 영구 실패 범주, 그다음 증거, 그다음 상태/실패별.

상태별 규칙에 수정 이력이 붙어 있다.

"408, 425 and 429 all mean the request reached the upstream and was answered, so repeating one is only safe under the same rule as every other repeat. These three used to skip that check: a non-idempotent POST answered 429 was retried, and a rate-limited upstream that had already accepted the work got it a second time. A 429 is a scheduling signal, never a statement that nothing happened."

그리고 RetryContext.safelyIdempotent()가 이 모듈의 D-09를 구현한다 — HTTP 메서드는 RetryContext아예 없다("so a POST with a registered idempotency key and a GET against a non-idempotent RPC endpoint are both handled correctly instead of by method-name folklore"). 키 기반 멱등성은 키가 실제로 전송됐는지까지 요구한다.

"The two used to be conflated: the platform read idempotencyKey.isPresent(), concluded the upstream could deduplicate, and retried — while the header was never sent, so the upstream had nothing to deduplicate against and processed the request twice. Possession of a key is the caller's intent; transmission is the upstream's ability to honour it."

test aKeyThatWasNeverSentDoesNotMakeARepeatSafe가 그것을 고정한다.

Retry-After는 남은 deadline 안에 들어갈 때만 존중된다(allowWithin), 그리고 존중된 Retry-AftermaxBackoff로 잘리지 않는다 — 잘라 버리면 업스트림이 요청한 대기보다 일찍 다시 두드리게 되기 때문이다.

21. 가드 순서와 그 근거

AttemptResiliencePipeline이 물리 시도마다 Circuit Breaker → Rate Limiter → Bulkhead → HTTP 호출을 고정 순서로 적용하고 역순으로 해제한다.

"The order is not cosmetic. An open circuit must reject before a rate token or a bulkhead permit is spent, otherwise a dead upstream keeps consuming the quota and concurrency that healthy upstreams need." "A local rejection (rate limiter or bulkhead) is deliberately not recorded as a circuit error: the upstream never saw the request, and counting our own back-pressure as upstream failure would open the breaker on a healthy dependency."

그리고 브레이커가 무엇을 보는지에 대한 수정 이력이 하나 더 있다 — 이전에는 원시 전송만 파이프라인 안에서 돌고 응답→예외 매핑이 밖에서 일어나서 "a 503 completed the call normally, the breaker recorded a success, and an upstream that answered nothing but 503 never opened its circuit. The thing the breaker is for was the one thing it could not see." 지금은 remoteFailure 분류기가 반환값을 보고 브레이커에 알린다.

test 47개가 이 규칙들을 촘촘히 덮는다 — appliesCircuitThenRateLimiterThenBulkheadPerAttempt, openCircuitDoesNotConsumeRateOrBulkheadPermit, bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError, answeredStatusesDoNotRetryANonIdempotentOperation, deniesOneShotBodyEvenForPut, honorsRetryAfterOnlyInsideDeadline, protocolProofOfNonProcessingWinsOverEverything, streamAfterGoAwayLastIdIsPeerNotProcessed 등.

Http2ProtocolEvidence는 프로토콜 수준 증거를 다룬다 — REFUSED_STREAM과 GOAWAY의 last-stream-id보다 큰 스트림 id는 피어가 처리하지 않았음의 증명이라 NOT_SENT로 승격되고, 그 이하 id의 리셋은 여전히 모호하다(streamAtOrBelowGoAwayLastIdStaysAmbiguous, aBareStreamResetProvesNothing).

22. P2 — 로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다

AttemptResiliencePipeline.execute의 진입부다.

83:    if (!circuitBreaker.tryAcquirePermission()) {  throw HttpCircuitOpenException }
88:    if (!rateLimiter.tryAcquirePermission()) {
89:      rejections.rateLimited();
90:      throw new HttpRateLimitRejectedException();        // ← 회로 permission 미반환
92:    }
93:    if (!bulkhead.tryAcquire()) {
94:      rateLimiter.onCompleted();                           // ← rate 토큰은 반환한다
95:      rejections.bulkheadRejected();
96:      throw new HttpBulkheadRejectedException();          // ← 회로 permission 미반환
98:    }

83행에서 회로 permission을 얻은 뒤, 88행과 93행의 로컬 거부 두 경로는 onSuccess·onError·releasePermission 중 어느 것도 부르지 않고 던진다. releasePermission은 이 leaf와 app-bootstrap 어디에도 등장하지 않고(170-... §8.1, exit=1), AttemptCircuitBreaker 인터페이스에도 그 연산이 없다(tryAcquirePermission·onSuccess·onError·state 넷뿐).

Resilience4j에서 이것이 중요한 상태는 HALF_OPEN이다. 그 상태의 tryAcquirePermission()permittedNumberOfCallsInHalfOpenState 중 하나를 소비하고, 그 시험 슬롯은 onSuccess/onError/releasePermission 중 하나로만 돌아온다. 아무것도 부르지 않으면 슬롯은 영구히 소비된다.

실패 시나리오. 업스트림 장애로 회로가 OPEN → 대기 후 HALF_OPEN 전이 → 트래픽이 돌아오면서 평상시 부하에 맞춰 사이징된 로컬 rate limiter나 bulkhead가 거부 → 그 거부마다 시험 슬롯 하나가 사라진다. 허용된 시험 호출 수만큼 그런 거부가 나면 브레이커는 성공도 실패도 관측하지 못한 채 HALF_OPEN에 머문다. Resilience4j의 maxWaitDurationInHalfOpenState 기본값은 0(무한 대기)이므로, 회복한 업스트림에 대해 회로가 닫히지 않는다. 그리고 이 조건들은 우연히 겹치는 것이 아니라 회복 순간에 자연히 함께 일어난다 — 회로가 반쯤 열리는 바로 그때 트래픽이 몰린다.

비대칭이 이 finding을 뒷받침한다. bulkhead 거부 경로는 rateLimiter.onCompleted()rate 토큰을 명시적으로 돌려준다(94행) — 저자가 permit 반환을 의식하고 있었다는 증거다. 세 가드 중 둘은 반환되고 첫 번째만 반환되지 않는다.

test도 그 공백을 그대로 보여 준다 — openCircuitDoesNotConsumeRateOrBulkheadPermit(회로가 거부할 때 뒤의 둘을 소비하지 않음)과 bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError(bulkhead 거부가 rate를 돌려줌)는 있지만, "rate/bulkhead 거부가 회로 permission을 돌려준다"는 test는 없다.

판정: P2. 수정은 AttemptCircuitBreakerreleasePermission()을 더하고(Resilience4j CircuitBreaker.releasePermission()에 위임, alwaysClosed()는 no-op) 두 로컬 거부 경로에서 호출하는 것이다.

23. Confirmed — PARTIAL_RESPONSE 재시도 분기는 도달 가능하다 (후보 → 결함 아님)

decide의 40~46행은 evidence == PARTIAL_RESPONSE일 때 안전 멱등이면 재시도를 허용한다. 그런데 28행이 이미 context.firstByteDelivered()에서 거부한다. DefaultExecutionEvidenceClassifierprogress.responseBytesDelivered() > 0 || progress.firstByteDelivered()일 때 PARTIAL_RESPONSE를 내고, AttemptProgressTracker.recordDeliveredBytes가 두 필드를 함께 세팅한다. 여기까지만 보면 40행은 도달 불가로 보인다.

전수 추적한 결과 도달 가능하다. PARTIAL_RESPONSE를 만드는 곳이 evidence classifier 하나가 아니다 — 전송 실패 분류기 넷이 엔진 예외로부터 직접 그 값을 만든다(ApacheFailureClassifier:121,136 · ReactorFailureClassifier:94,114 · JdkFailureClassifier:83,103 · JettyHttp3FailureClassifier:41). 그 경로는 tracker의 firstByteDelivered를 보지 않으므로, "엔진은 응답 일부를 봤지만 호출자에게는 한 바이트도 전달되지 않은" 상태가 표현된다. 40행 주석이 말하는 구분("A partial response that never reached the caller may still be retried… once a byte was delivered the earlier guard has already denied it")이 실제로 성립한다.

FirstByteRetryBoundaryTest가 그 경계를 양쪽에서 고정한다. 결함 아님으로 판정.

24. Negative-space probes — sub-scope 03

  • 8.1 permit 반환 대칭: 세 가드의 획득/반환 경로 전수 추적 — 회로만 반환 없음(§22). releasePermission 저장소 전체 매치 0.
  • 8.2 분기 도달성: PARTIAL_RESPONSE 생산 지점 전수 조사로 후보를 오탐 판정(§23).
  • 8.3 결정표 순서: 절대 차단 6 → 영구 실패 → 증거 → 상태/실패별의 단조성 확인(§20).
  • 8.4 test 밀도: main 39 파일에 test 8 파일 / 47 메서드. 상태별·증거별·본문별 갈래가 이름으로 고정됨.

25. Sub-scope 03 findings backlog

우선순위 finding reachability
P2 rate limiter·bulkhead 로컬 거부 경로가 회로 브레이커 permission을 반환하지 않는다 — HALF_OPEN 시험 슬롯이 소진되어 회복한 업스트림에 대해 회로가 닫히지 않을 수 있다 회로 회복 중 로컬 백프레셔가 걸리는 배포

26. Sub-scope 03 완료 조건

  • denominator 47 / 47 FULL_READ (170-... OWNED FILES)
  • §8.1~§8.4 probe 수행, permit 반환 대칭 조사 1건
  • 후보 finding 1건(PARTIAL_RESPONSE 분기 도달 불가 의심)을 생산 지점 전수 조사로 오탐 판정(§23)
  • 소스 미변경

27. Sub-scope 04 범위와 denominator

내부 상태: COMPLETE — 46 / 46 FULL_READ 범위: restclient/** 19 + webclient/** 17 (main 36, 3,476 LOC) + 전용 test 10 역할: 블로킹/리액티브 두 게이트웨이 — 준비 · 시도 실행 · 응답 매핑 · 스트리밍 · SSE

manifest와 probe: evidence/raw/171-httpclient-gateway-probes.txt.

28. 두 예산, 두 계층, 그리고 읽는 도중의 강제

ResponseSizeLimiter와이어 바이트와 디코드 바이트를 따로 센다 — "a compressed payload passes a wire check and then expands, so a single limit either rejects legitimate traffic or lets a decompression bomb through." 그리고 CountingBoundedInputStream이 상한을 읽는 도중에 적용한다 — "a response that is discovered to be too large only once it is fully buffered has already cost the memory the limit exists to protect."

BoundedErrorBody는 오류 본문을 RFC 9457 문서를 해독할 만큼만 읽고, "The bytes never reach an exception message or a log." toString()은 길이와 truncated 여부만 낸다.

RemoteProblemDecoder의 규칙 한 줄이 이 계층의 성격을 요약한다 — "The wire status wins. A remote status member is read and discarded, because trusting it would let an upstream relabel a 503 as a 400 and change our retry behaviour from its own body." 확장 속성도 allowlist로 걸러 "an upstream cannot inject unbounded attributes into our telemetry." test 넷이 그 갈래를 고정한다(mapsProblemJsonWithoutTrustingBodyStatus·dropsExtensionsThatAreNotAllowlisted·treatsANonProblemContentTypeAsAnEmptyProblem·survivesAnUnparseableProblemDocument).

29. 리다이렉트는 엔진이 아니라 이 플랫폼이 따라간다

모든 전송에서 엔진 리다이렉트를 끄고 BlockingRedirectCoordinator만이 hop을 만든다. 각 hop마다 대상 정책을 다시 적용하고 origin이 바뀌면 자격증명을 떨어뜨린다 — "which is exactly what an engine's built-in follower does not do."

수정 이력 둘이 붙어 있다.

  • "The redirect policy decides whether a hop is permissible in shape; the profile decides whether its destination is permissible at all. Only the first check existed, so an upstream could redirect a trusted profile to an origin its allowlist excluded." 지금은 targetGuard.requireAllowed(follow.target(), metadata)가 매 hop 실행된다.
  • 303은 메서드를 GET으로 바꾸면서 본문도 버린다 — "Changing only the method sent the original payload as a GET body to a destination the upstream chose." 301/302는 메서드를 유지한다("the platform refuses to guess a rewrite the caller did not ask for").

hop 상한은 RedirectEvaluator:16context.hop() >= context.policy().maxHops()로 강제하고 test stopsAtTheConfiguredHopLimit이 잡는다 — 조정자의 for 루프에 종료 조건이 없어 보이는 것은 평가기가 거부로 끝내기 때문이다(후보 추적 → 결함 아님).

그리고 hop은 재시도가 아니다 — "A hop is a physical request: it passes through the resilience pipeline via the supplied sender, so it consumes rate and bulkhead capacity. It is not a retry, because nothing failed."

30. P3 — BoundedDataBufferFlux의 두 연산자가 이름만 있고 아무것도 하지 않는다

클래스 javadoc이 목적을 적는다 — "Bounds a reactive body and releases every buffer it does not hand on… Cancellation and error are the paths that leak in practice: the subscriber stops asking, the upstream drops what it already produced, and those buffers are direct memory nobody returns."

그런데 그 두 경로에 붙은 연산자가 둘 다 비어 있다.

return source
    .doOnNext(buffer -> { limiter.recordWireBytes(); guard.markDelivered(); })
    .doOnDiscard(DataBuffer.class, DataBufferUtils::release)   // ← 실제로 일하는 유일한 연산자
    .doOnCancel(() -> {})                                      // ← no-op
    .onErrorResume(failure -> Flux.error(failure));            // ← 같은 오류를 그대로 재방출, no-op

doOnCancel(() -> {})은 정의상 아무 일도 하지 않고, onErrorResume(f -> Flux.error(f))는 오류 신호에 대해 항등이다. 따라서 이 클래스의 버퍼 해제는 전적으로 doOnDiscard와 드라이버 자신의 해제 동작에 의존한다.

누수가 실재한다고 주장하지는 않는다 — Reactor Netty의 ByteBufFlux는 취소 시 미방출 버퍼를 스스로 해제하고, doOnDiscard는 discard 프로토콜을 지원하는 연산자에 대해 동작한다. 문제는 코드가 하지 않는 일을 하는 것처럼 읽힌다는 것이다: 두 누수 경로의 이름을 딴 연산자가 나란히 있고 둘 다 비어 있어서, 이 클래스를 읽는 사람은 취소·오류 해제가 여기서 명시적으로 처리된다고 결론짓게 된다. 취소 경로에 test가 없지는 않다 — ReactiveStreamingLifecycleTest.cancellationReleasesTheConnectionForTheNextCall이 있다. 다만 그것이 확인하는 것은 연결 반환이고 버퍼 해제가 아니며, 실질적 안전망은 모든 lane에 켜져 있는 Netty leak detector(paranoid)다(§0).

판정: P3. 수정은 두 연산자를 지우고 javadoc이 doOnDiscard와 드라이버의 역할을 정확히 적게 하거나, 취소 경로에서 실제로 해제해야 할 것이 있다면 그것을 구현하는 것이다.

31. Negative-space probes — sub-scope 04

  • 8.1 죽은 연산자: BoundedDataBufferFlux의 4개 연산자 중 2개가 no-op(§30). 소비자는 ReactiveStreamingGateway:102 하나.
  • 8.2 편의 생성자의 가짜 신원: ResponseSizeLimiter의 2인자 생성자는 프로파일 이름이 리터럴 "response-size-limiter"인 정적 메타데이터를 쓴다. production 호출 지점 셋(BlockingAttemptExecutor:52·BlockingStreamingGateway:60·ReactiveStreamingGateway:69)은 전부 3인자 생성자로 실제 메타데이터를 넘긴다 — 2인자 형태는 test 전용이다. 결함 아님으로 판정하되, public이므로 fork가 쓰면 예외에 가짜 프로파일 이름이 실린다.
  • 8.3 hop 상한: 조정자 루프의 종료 조건 부재를 후보로 추적해 RedirectEvaluator의 거부로 확정 — 오탐(§29).
  • 8.4 상태 신뢰 경계: 원격 problem 문서의 status가 폐기되고 와이어 상태가 이긴다는 규칙을 코드와 test로 확인(§28).

32. Sub-scope 04 findings backlog

우선순위 finding reachability
P3 BoundedDataBufferFluxdoOnCancel(() -> {})onErrorResume(f -> Flux.error(f))가 no-op인데, 클래스 javadoc은 그 두 경로를 이 클래스가 처리한다고 적는다 코드 독해 · 회귀

33. Sub-scope 04 완료 조건

  • denominator 46 / 46 FULL_READ (171-... OWNED FILES)
  • §8.1~§8.4 probe 수행
  • 후보 finding 2건(리다이렉트 hop 무한 루프 의심, ResponseSizeLimiter 가짜 메타데이터)을 각각 평가기 거부와 호출 지점 전수로 추적해 결함 아님으로 판정(§29, §31)
  • 소스 미변경

34. Sub-scope 05 범위와 denominator

내부 상태: COMPLETE — 46 / 46 FULL_READ 범위: security/** 21 + auth/** 18 (main 39, 2,072 LOC) + 전용 test 7 역할: 목적지·헤더·본문 정책, TLS 재료와 회전, 자격증명 해석

manifest와 probe: evidence/raw/172-httpclient-security-auth-probes.txt.

35. 목적지 정책 — 절대 URI를 정화하지 않고 거부한다

TrustedTargetPolicy의 규칙 — "An absolute URI is rejected here rather than sanitised: H2 exists to vary method, relative path, query, approved headers, and body — not the destination. Changing the destination is what H3 is for, and H3 has its own policy, credentials, and DNS validation."

requireRelativeTemplate가 빈 템플릿, // 시작, :// 포함, /로 시작하지 않음을 거부한다. 확장은 문자열 연결이 아니라 Spring DefaultUriBuilderFactoryTEMPLATE_AND_VALUES 인코딩이라 "a value containing /, ?, or # cannot change the shape of the request." 그리고 확장 후에 requireAllowedOrigin이 host/port allowlist를 다시 본다.

멱등성 키 처리에 수정 이력 둘이 붙어 있다.

"Before, the key was carried on the operation, checked for presence by the retry engine, and never written to the wire: the upstream saw no key, could not deduplicate, and the platform meanwhile treated a repeat as contractually safe. A duplicated payment is the shape of that bug." "A caller-supplied value for the same header is refused rather than merged. Two keys for one request is a contradiction."

그리고 리다이렉트 hop에 allowlist를 다시 적용하는 requireAllowedTarget이 public인 이유도 적혀 있다 — 조정자가 이전에는 리다이렉트 정책만 보고 프로파일 allowlist를 보지 않아 "An upstream could therefore redirect a trusted profile to any origin the redirect policy tolerated, including one the operator had explicitly excluded."

36. 헤더 소유권과 자격증명 제거

HeaderPolicyPLATFORM_OWNED 9종(authorization·proxy-authorization·host·content-length·transfer-encoding·traceparent·tracestate·baggage·cookie)은 호출자가 덮을 수 없고, CR/LF는 무조건 거부된다 — "a header value that can contain a newline is a request-splitting primitive."

SensitiveHeaderStripper에 이 sub-scope에서 가장 미묘한 수정이 있다.

"It adds rather than replaces, which its name always claimed and its behaviour did not. A profile that named a custom API-key header — X-Client-Key, say — produced a stripper that dropped only that one and forwarded X-Api-Key across an origin boundary, so configuring a custom header made the default headers less protected than leaving it alone."

37. 자격증명은 값이 아니라 신원만 남긴다

SingleFlightTokenLoader가 토큰 갱신을 한 번으로 접고, 그 실행 위치에 대한 두 가지 과거 오류를 적는다 — 공용 ForkJoinPool에서 돌아 "That pool is sized for CPU-bound work"였고, 대기가 **무한 join()**이라 "A token endpoint that accepted the connection and never answered" 상황에서 전체가 멈췄다.

UnauthorizedRetryPolicy의 재시도 상한이 1인 이유 — "An expired token produces a 401 that a refresh fixes; a wrong scope produces a 401 that no number of refreshes fixes." 그리고 재생 가능한 본문과 (읽기 전용이거나 명시적 키를 가진) 연산만 허용한다. test 셋이 그 갈래를 고정한다(denies401ReplayForOneShotPost·allowsExactlyOneReplayForASafeReplayableOperation·allowsAKeyedWriteOnlyWhenAuthFailedBeforeAnySideEffect).

AccessTokenRequestCredentialstoString()을 REDACTED로 재정의하고, CredentialRequest도 마찬가지인데 그 이유가 구체적이다 — "The generated toString printed the authenticated principal and the full target URI, including any query string. A credential-resolution failure is exactly when this record ends up in a log line, which made the failure path the most likely place for a user identity and a signed URL to escape."

OAuth2TokenCacheKey가 6개 성분을 모두 키에 넣는 이유도 적혀 있다 — "sharing a token across principals, scope sets, audiences, tenants, or client certificates is a privilege-escalation bug, not a cache optimisation."

TlsRuntimeRotationCoordinator는 회전을 세대 교체로 처리하고("a connection pool holds sockets that were negotiated with the old material"), 어느 프로파일이 참여하는지를 회전된 신원 이름으로 정한다 — 이전에는 "The identity argument used to be required and then ignored: every registered profile was" 회전 대상이었다.

38. Negative-space probes — sub-scope 05

  • 8.1 자격증명 유출 표면: auth의 18 타입 중 record 5종을 전수 확인 — 값을 담는 셋(AccessToken·RequestCredentials·CredentialRequest)은 모두 redacted toString을 갖고, OAuth2TokenCacheKey·UnauthorizedRetryContext는 비밀을 담지 않는다. 나머지 13종은 final class/interface/enum이라 생성된 toString이 없다.
  • 8.2 가짜 메타데이터 편의 생성자: HeaderPolicy.validate(input) 1인자 오버로드가 정적 UNBOUND(프로파일 이름 리터럴 "header-policy")를 쓰지만, production 호출 지점 둘(TrustedTargetPolicy:80·DefaultDynamicTargetGateway:156)은 전부 2인자 형태로 실제 메타데이터를 넘긴다 — sub-scope 04의 ResponseSizeLimiter와 같은 형태이며 같은 결론(결함 아님).
  • 8.3 SSRF 방어 지점 전수: requireAllowedTarget/requireAllowedOrigin의 호출 지점 셋(준비 시 1, 리다이렉트 hop 1, 내부 1)과 동적 대상의 IpAddressClassifier/ValidatedDnsResolver(sub-scope 06 범위)를 확인.
  • 8.4 소유 헤더 목록: PLATFORM_OWNED 9종과 ALWAYS_STRIPPED 4종 + 관례적 API 키 2종의 관계 확인.

39. Sub-scope 05 findings backlog

우선순위 finding reachability
없음. 목적지가 정화가 아니라 거부로 다뤄지고, 헤더 소유권과 CR/LF 거부가 타입 수준에 있으며, 자격증명을 담는 모든 값 타입이 redacted toString을 갖는다

40. Sub-scope 05 완료 조건

  • denominator 46 / 46 FULL_READ (172-... OWNED FILES)
  • §8.1~§8.4 probe 수행, 자격증명 유출 표면 전수 조사
  • 후보 finding 1건(HeaderPolicy의 가짜 메타데이터)을 호출 지점 전수로 추적해 결함 아님으로 판정(§38)
  • 소스 미변경

41. Sub-scope 06 범위와 denominator

내부 상태: COMPLETE — 56 / 56 FULL_READ 범위: service 14 + dynamic 11 + observation 6 + migration 4 (main 35, 2,698 LOC) + test 21 (전용 11 + contract 7 + architecture 3) 역할: 선언적 서비스 클라이언트, 동적 대상(SSRF 방어), 관측 태그, RestTemplate 이관 진단

manifest와 probe: evidence/raw/173-httpclient-service-dynamic-probes.txt.

42. 동적 대상 — SSRF 방어가 소켓까지 이어진다

이 sub-scope의 중심은 CallScopedDnsPin의 javadoc이 적는 과거 결함이다.

"This is the piece the SSRF defence was missing. ValidatedDnsResolver resolved a host, rejected the target if any answer was forbidden, and produced a PinnedTarget holding the exact approved addresses — and then the gateway handed the transport a URL containing the hostname, and the transport resolved it again. Everything between the two resolutions was unvalidated: a DNS server under an attacker's control answers the first query with a public address and the second with 169.254.169.254, and the platform connects to the metadata service having 'validated' the target. The classic rebinding attack, defeated by a check that discarded its own result."

지금은 사슬이 닫혀 있다. TargetCanonicalizer가 순서대로 파싱·userinfo 거부·Punycode 정규화·allowlist 비교를 하고("Order is the security property"), ValidatedDnsResolver모든 응답을 검사하며("Validating only the first answer is a common and fatal shortcut"), DefaultDynamicTargetGateway:105-121이 hop마다 CallScopedDnsPin을 열고 finally에서 resolver.forget(...)을 부른다. 그리고 전송 쪽은 app-bootstrap HttpClientTransportAutoConfiguration:81CallScopedDnsPin::addressesForValidatedAddressResolverGroup에 주입해 소켓이 핀에 없는 주소로 나가지 못한다 — 핀이 비면 "The transport must then refuse rather than fall back to a system lookup."

IpAddressClassifier도 두 가지를 명시한다 — IPv4-mapped IPv6를 되돌려 정규화하고, 구조가 "an allowlist of globally routable unicast space, then the operator's own" 형태다. 그리고 "Every rejection here used to be an acceptance. Integer.parseInt took -1…"라는 수정 이력이 있다.

관측도 닫혀 있다 — HttpClientTagPolicy모르는 태그 이름을 거부한다("a metric backend cannot undo a"高카디널리티 태그), SensitiveValueRedactor는 쿼리 값을 키별로 마스킹하지 않고 통째로 버린다("an allowlist of 'safe' query" 키를 유지하는 방식은 안전하지 않다는 판단).

계약 lane의 메타 test가 특히 좋다 — everySelectedBlockingTransportRanEveryContract, everySelectedReactiveTransportRanEveryContract, everySelectedTransportIsClaimedByExactlyOneContainer. lane이 "무언가 돌았다"가 아니라 **"선택된 전송 각각이 모든 계약을 돌았다"**를 확인한다. HttpClientModuleBoundaryTest는 설계의 19-모듈 의존 표를 패키지 경계로 강제하고, PublicApiArchitectureTest.coreApiDependsOnNothingInsideThePlatform이 공개 API의 독립성을 잡는다.

43. Confirmed — ValidatedDnsResolverapproved 맵은 hop마다 비워진다 (후보 → 결함 아님)

javadoc이 "They are deliberately not retained here between calls"라고 적는데 approved ConcurrentHashMap 필드는 여전히 존재하고 resolve()마다 채워진다. 무경계 증가로 보였으나 전수 추적 결과 DefaultDynamicTargetGatewayfinally에서 resolver.forget(host)를 부르므로(:118-122) 항목은 hop 종료와 함께 사라진다. 그리고 보안 경로는 이 맵이 아니라 thread-local 핀을 쓴다 — approvedAddresses(String)의 production 호출자는 0이고 test·testkit 관측용이다. 결함 아님.

(맵이 host만으로 키를 잡으므로 같은 host에 대한 동시 호출은 항목을 공유하고 먼저 끝난 쪽이 forget한다. 보안 결정은 thread-local이 내리므로 결과에 영향이 없다.)

44. Sub-scope 06 findings backlog

우선순위 finding reachability
없음. 정규화→검증→핀→소켓 사슬이 닫혀 있고, 관측 태그가 allowlist로 폐쇄되며, 계약 lane이 전송별 완전 실행을 메타 test로 확인한다

45. Sub-scope 07 범위와 denominator

내부 상태: COMPLETE — 85 / 85 FULL_READ 범위: transport 10 + reactor 6 + apache 5 + jdk 4 + http3 4 + spring7 3 (main 32, 2,189 LOC) + 전용 test 9 + testkit source set 35 (2,754 LOC) + httpClientPerformanceTest 7 (495) + jmh 2 (130) 역할: 전송 제공자 6종과 그 능력 선언, 그리고 세 개의 보조 source set

manifest와 probe: evidence/raw/174-httpclient-transport-testkit-probes.txt.

46. 전송은 능력을 선언하고, 프로파일보다 약하면 startup이 실패한다

TransportCapabilityValidator가 프로파일이 요구하는 것과 전송이 선언한 것을 대조해 부족분을 이름으로 모아 거부한다 — 프로토콜, route pool, 유계 pending 큐, proxy, mutual TLS, 동적 대상 안정성. 메시지는 "profile settings and capability names only — never a URL, address, or secret."

능력 레코드가 그 선언을 데이터로 만든다. ReactiveTransportCapabilities.reactorNetty()는 9개 능력을 전부 true로, jettyHttp3Experimental()은 route pool·유계 큐·DNS 핀·동적 안정성을 false로 선언한다. HTTP/3는 compileOnly 의존이라 클래스가 없으면 Http3CapabilityReport가 전송을 거부한다 — "the failure mode is a startup error rather than a NoClassDefFoundError mid-call"(§0).

testkit이 별도 source set인 것도 이 sub-scope의 성격이다 — 계약을 담은 클래스 35개(BlockingTransportContract·ReactiveTransportContract·RetrySafetyContract·ResourceLifecycleContract·ObservabilityContract·DynamicTargetSecurityContract)를 test·performance·jmh 세 lane이 공유한다. NettyLeakDetectionExtension은 leak detector 레벨을 믿지 않고 확인한다 — "asserts the level rather than trusting the flag reached the forked JVM"(§0).

성능 lane 7개는 자원 상한을 검증한다 — PoolSaturationPerformanceTest·RetryStormBudgetTest·RuntimeRotationDrainTest·OAuthRefreshContentionTest·LargeBodyResourceTest·Http2StreamSaturationTest. §15에서 남긴 질문(ObjectBody.replayability()의 반사 비용을 재는 lane이 있는가)의 답은 없다 — 성능 lane은 풀·재시도·회전·토큰 경합·본문 크기·H2 스트림을 재고 본문 재생 가능성 판정 비용은 재지 않는다.

47. P3 — 동적 대상 DNS 핀 능력 검사가 블로킹 오버로드에만 있다

TransportCapabilityValidator에는 오버로드가 둘이다. 블로킹 쪽에는 이런 검사와 주석이 있다.

if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) {
  // `validatedDnsPinning` was declared on every capability record and read by nothing. It is
  // the capability that decides whether the SSRF address validation survives to the socket, so
  // a transport that does not have it cannot serve a dynamic target no matter what its
  // `dynamicTargetStable` flag says — the two were being conflated.
  missing.add("call-scoped validated DNS pinning");
}

리액티브 오버로드에는 이 검사가 없다. dynamicTargetStable만 본다 — 즉 주석이 "conflated"라고 지적한 바로 그 상태가 리액티브 경로에 그대로 남아 있다.

지금 노출은 없다. 두 리액티브 전송의 두 플래그가 같은 값이기 때문이다 — reactorNetty()는 둘 다 true, jettyHttp3Experimental()은 둘 다 false. 게다가 ClientProfileValidatorDYNAMIC + JETTY 조합을 이미 거부한다(DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED).

판정: P3. 위험은 fork가 리액티브 전송을 추가하면서 dynamicTargetStable=true, validatedDnsPinning=false로 선언하는 경우 — 주석이 "cannot serve a dynamic target no matter what its dynamicTargetStable flag says"라고 못박은 정확히 그 조합이 리액티브 쪽에서는 통과한다. 수정은 같은 세 줄을 리액티브 오버로드에 복사하는 것이다.

48. Negative-space probes — sub-scope 06·07

  • 8.1 SSRF 사슬의 끝점: 정규화 → 전체 응답 검증 → thread-local 핀 → 전송 resolver 주입까지 호출 지점으로 전수 추적(§42).
  • 8.1b 잔존 캐시: ValidatedDnsResolver.approved의 수명과 production 호출자 0을 확인해 오탐 판정(§43).
  • 8.2 조건부 형제: TransportCapabilityValidator의 두 오버로드 비교 — 한쪽에만 적용된 수정(§47).
  • 8.3 능력 선언 대 실제: 두 리액티브 전송의 9개 능력 플래그 전수 확인(§46, §47).
  • 8.4 lane 완전성: 계약 lane의 메타 test 3종과 성능 lane 7종의 대상 범위 확인(§42, §46).

49. Sub-scope 06·07 findings backlog

우선순위 finding reachability
P3 동적 대상의 validatedDnsPinning 능력 검사가 TransportCapabilityValidator의 블로킹 오버로드에만 있고 리액티브 오버로드에는 없다 — 주석이 "conflated"라고 지적한 상태가 한쪽에 남아 있다 dynamicTargetStable=true, validatedDnsPinning=false인 리액티브 전송을 추가하는 fork

50. Sub-scope 06·07 완료 조건

  • denominator 56 / 56 및 85 / 85 FULL_READ (173-..., 174-... OWNED FILES)
  • §8.1~§8.4 probe 수행, 조건부 형제 비교 1건
  • 후보 finding 1건(approved 맵 무경계 증가 의심)을 finally 배치와 호출자 전수로 추적해 결함 아님으로 판정(§43)
  • §15에서 남긴 성능 lane 질문을 해소(§46)
  • 소스 미변경

51. 교정 — 영구 TLS 실패의 CONNECT 분류는 분류기 결함이 아니라 픽스처의 듀얼스택 호스트명이다

이 절은 이전 사이클이 여기에 적었던 P1 진단을 철회하고 교체한다. 관측된 실패는 그대로 재현되지만, 그 원인으로 지목했던 기전은 측정으로 반증되었다. 근거는 EVD-332다.

51.1 관측은 그대로다

:adapter:outbound:httpclient:test 는 HEAD 에서도 283 중 3건 실패한다.

anUntrustedAuthorityIsAPermanentTlsFailure()  → expected: TLS_HANDSHAKE  but was: CONNECT
anExpiredCertificateIsAPermanentTlsFailure()  → expected: TLS_HANDSHAKE  but was: CONNECT
aHostnameMismatchIsAPermanentTlsFailure()     → expected: TLS_HANDSHAKE  but was: CONNECT

세 건 모두 MutualTlsHandshakeContractTest.java:168assertThat(classified.stage()).isEqualTo(TLS_HANDSHAKE) 다. 바로 앞줄인 167행(evidence == NOT_SENT)은 통과한다.

51.2 철회하는 진단

이전 사이클의 주장은 이랬다.

Apache HttpClient 5는 TLS 핸드셰이크 실패를 연결 단계 실패로 감싼다 — 사슬이 HttpHostConnectExceptionSSLHandshakeException이다. 바깥 것이 CONNECT 분기에 먼저 걸리므로 안쪽 SSLHandshakeException은 검사되지 않는다.

틀렸다. 그 진단은 ApacheFailureClassifier.recognize의 분기 순서(pool → DNS → CONNECT → TLS)를 읽고 사슬의 모양을 추론한 것이지, 사슬을 실제로 떠본 것이 아니다. 잡힌 예외를 그대로 출력하면 이렇다.

[0] ResourceAccessException  :: ... Connect to https://localhost:56507 failed: Connection refused
[1] HttpHostConnectException :: Connect to https://localhost:56507 failed: Connection refused

사슬에 SSLHandshakeException없다. 그림자에 가려진 것이 아니라 애초에 도착하지 않았다. 분류기는 자기가 받은 것을 정확히 분류했다.

51.3 확정된 기전 — 접속 호스트만 바꾼 대조

동일한 서버 객체, 동일한 클라이언트 신뢰재료. baseUrl의 호스트 문자열만 바꿨다.

=== untrusted-authority @127.0.0.1 ===
  [1] javax.net.ssl.SSLHandshakeException :: (bad_certificate) PKIX path validation failed
  [4] java.security.SignatureException :: Signature does not match.
  -> stage=TLS_HANDSHAKE  category=TLS_PERMANENT  permanent=true     ← test가 기대하는 값

=== untrusted-authority @localhost ===
  [1] HttpHostConnectException :: Connect to https://localhost:40485 failed: Connection refused
  -> stage=CONNECT  category=CONNECT  permanent=false                ← test가 본 값

이 컨테이너의 /etc/hostslocalhost를 두 패밀리에 준다.

127.0.0.1  localhost
::1        localhost ip6-localhost ip6-loopback
InetAddress.getAllByName("localhost") -> [127.0.0.1, 0:0:0:0:0:0:0:1]

MockWebServer는 IPv4 루프백에만 바인딩하고, MockHttpServer.uri()는 호스트명 localhost를 돌려준다 (MockHttpServer.java:70-72). Apache HttpClient 5의 연결 오퍼레이터는 해석된 주소를 순회하면서 마지막이 아닌 주소의 실패를 삼킨다.

127.0.0.1 → TCP 성공 → TLS 핸드셰이크 실패(진짜 실패)  → 삼켜짐
::1       → TCP 거부(듣는 소켓 없음) → 마지막 주소     → HttpHostConnectException 으로 승격

호출자에게 도달하는 유일한 예외는 두 번째 주소의 연결 거부다.

핸드셰이크가 성공하는 test가 통과하는 이유도 같은 루프다. 127.0.0.1 에서 성공하면 루프가 즉시 반환하므로 ::1을 시도하지 않는다. 따라서 처음 눈에 띄었던 startTls(..., true/false) 차이는 원인이 아니라 상관관계였다 — 실패하는 케이스가 곧 두 번째 주소까지 가는 케이스다.

startTls(..., false)에서 서버가 뜨지 않는다는 가설도 함께 기각했다. 두 경우 모두 원시 소켓 접속이 성공한다(raw 127.0.0.1: OK, raw localhost: OK).

51.4 두 개의 판정

(a) test 실패 자체 — P3, 픽스처 결함. 프로덕션 코드에 결함이 없다. localhost가 IPv4로만 풀리는 환경에서는 세 건 모두 통과한다. 고칠 것은 MockHttpServer.uri()가 호스트명을 돌려준다는 점이다 — 루프백 IPv4 주소를 돌려주거나 test가 주소를 고정하면 사라진다. 이 저장소는 preferIPv4Stack을 어디에도 설정하지 않으므로, 듀얼스택 CI/컨테이너에서 이 세 건은 항상 빨갛다.

(b) 이 실패가 드러낸 런타임 성질 — P2/기록, 이 모듈에서 고칠 수 없다. 분류표는 두 범주를 정반대로 다룬다.

분류 DefaultRetryEligibilityEngine
TLS_PERMANENT permanent()RetryDenied.permanentFailure (37-39행)
CONNECT failureDecisioncase CONNECT -> RetryAllowed.of("CONNECT") — 멱등성과 무관하게 재시도

다중 주소 호스트에서 한 패밀리는 TLS를 영구 거절하고 다른 패밀리는 연결을 거부하면, 절대 검증되지 않을 인증서에 대한 호출이 CONNECT로 분류되어 예산·데드라인이 소진될 때까지 매 시도 재시도된다. 분류기가 볼 수 있는 정보 안에서 이 강등을 막을 방법은 없다 — Apache의 루프가 앞선 주소의 실패를 이미 버렸기 때문이다. 이 모듈의 설계 전제는 "증거에 기반해 재시도 안전성을 판정한다"인데, 증거를 만드는 계층이 증거의 일부를 버리는 지점이 여기다. 다만 인증서가 신뢰 불가면 보통 두 패밀리 모두 TLS에서 실패하고, 그때는 마지막 주소의 예외도 SSLHandshakeException이라 올바르게 TLS_PERMANENT가 된다. 강등은 패밀리별 실패 양상이 다를 때만 일어난다.

51.5 이전 사이클이 남긴 열린 항목의 처리

이전 §51은 "ReactorFailureClassifier·JdkFailureClassifier도 같은 사슬 순회 형태를 쓰므로 같은 그림자 문제가 있는지 확인이 필요하다"로 끝났다. 그 열린 항목은 전제가 반증되어 소멸한다 — Apache는 TLS 실패를 연결 예외로 감싸지 않는다(§51.3의 @127.0.0.1 측정이 직접 보여 준다). 사슬 순회 순서가 TLS를 가리는 일은 이 경로에서 일어나지 않는다. 다른 두 분류기를 같은 방식으로 개별 실행해 보지는 않았다. 확인하지 못한 것으로 남긴다.

52. 모듈 ledger 정합

# 범위 main test 기타 FULL_READ probe
1 governance + profile/** 29 2 4 35 35 167, 168
2 api/** 50 5 55 55 169
3 resilience/** 39 8 47 47 170
4 restclient/** + webclient/** 36 10 46 46 171
5 security/** + auth/** 39 7 46 46 172
6 service+dynamic+observation+migration 35 21 56 56 173
7 전송 6종 + testkit/perf/jmh 32 9 44 85 85 174
TOTAL 260 62 48 370 370 7 / 7

coverage ledger: FULL_READ 370 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / 미분류 0.

53. 모듈 findings

# 우선순위 finding 위치
1 P3 MutualTlsHandshakeContractTest 3건이 실패하지만 원인은 프로덕션 코드가 아니라 픽스처다 — MockHttpServer.uri()가 듀얼스택 localhost를 돌려주고 MockWebServer는 IPv4에만 바인딩한다 (이전 사이클의 P1 진단은 EVD-332로 철회) §51
1b P2/기록 다중 주소 호스트에서 패밀리별 실패 양상이 다르면 영구 TLS 실패가 재시도 가능한 CONNECT로 강등된다 — Apache가 앞선 주소의 실패를 버리므로 이 모듈에서 고칠 수 없다 §51.4
2 P2 rate limiter·bulkhead 로컬 거부 경로가 회로 브레이커 permission을 반환하지 않는다 — HALF_OPEN 시험 슬롯 소진으로 회복한 업스트림에 회로가 닫히지 않을 수 있다 §22
3 P3 ClientRuntimeRegistry.close()가 실패 시 drain 스케줄러 종료에 도달하지 않아 스레드가 남는다 §4
4 P3 POOL_ROUTE_EXCEEDS_TOTAL 위반 코드가 PoolSettings 생성자에 가려 도달 불가 §5
5 P3 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않고, 그 22종에 사고 유래 보안 가드가 대부분 포함된다 §6
6 P3 ObjectBody.deeplyImmutableinstanceof NumberAtomicInteger·LongAdder 등 가변 타입을 REPLAYABLE로 인증한다 §14
7 P3 BoundedDataBufferFluxdoOnCancel·onErrorResume가 no-op인데 javadoc은 그 두 경로를 이 클래스가 처리한다고 적는다 §30
8 P3 동적 대상 validatedDnsPinning 능력 검사가 TransportCapabilityValidator의 블로킹 오버로드에만 있다 §47
9 P3/기록 ObjectBody.replayability()가 호출마다 반사로 재계산되고 캐시가 없으며, 성능 lane도 이 경로를 재지 않는다 §15, §46

결함 아님으로 판정한 후보 6건PARTIAL_RESPONSE 분기 도달 불가 의심(§23), 리다이렉트 hop 무한 루프 의심(§29), ResponseSizeLimiter·HeaderPolicy의 가짜 메타데이터 생성자(§31, §38), ValidatedDnsResolver.approved 맵 무경계 증가 의심(§43).

54. 이 모듈에서 반복해서 나타난 패턴

  • 거부가 무시보다 낫다. 이 저장소의 다른 열 모듈에서 반복 발견한 "선언되었으나 아무것도 하지 않는 설정"을, 이 모듈은 validateUnsupportedSettings명시적 거부한다 — "the honest position is to refuse a value the platform cannot honour instead of accepting it and doing nothing."
  • 증거가 메서드 이름을 이긴다. RetryContext에 HTTP 메서드가 없고, 멱등성 키는 전송됐는지까지 요구하며, 첫 바이트 전달은 래치다.
  • 저장소에서 가장 밀도 높은 사고 기록. 34개 위반 코드, AttemptResiliencePipeline의 브레이커 가시성 수정, SensitiveHeaderStripper의 "adds rather than replaces", CallScopedDnsPin의 "a check that discarded its own result", TrustedTargetPolicy의 "A duplicated payment is the shape of that bug" — 각 가드가 자신이 막는 사고를 인용한다.
  • 그러나 그 규율이 test로 고정된 비율은 낮다. 위반 코드 34 중 12만 test가 이름으로 잡고(§6), 사고 유래 가드 대부분이 그 밖에 있다. 그리고 §51은 그 반대편의 함정이다 — test 3건이 빨간 채로 남아 있고, 이전 사이클은 그 빨강을 프로덕션 P1으로 읽었다. 실제로는 픽스처의 호스트명 문제였다(EVD-332). 실패하는 test는 결함의 증거가 아니라 조사의 시작점이다.

55. 검증

evidence/raw/175-httpclient-suite-verification.txt.

lane                            classes  tests  failures  skipped
test                                 61    283         3        0
httpClientStableContractTest          7     24         0        0
httpClientSecurityTest                2      9         0        0
httpClientBlockHoundTest              1      3         0        0
spring62ApiSurfaceScan                1      6         0        0

git status --short → 0

두 가지를 그대로 기록한다.

  1. :check는 소스와 무관한 이유로 실패한다. src/gradle/archive-hygiene.gradle의 게이트가 build/libs에 남은 이전 리비전 JAR 두 개(+0137263441f6, +e98b56eb03ec)를 발견하고 빌드를 깬다. 저장소가 cleanStaleTraceableJars라는 remedy 태스크를 제공한다. 이 분석은 사용자 워크스페이스의 빌드 산출물을 삭제하지 않았고, 대신 다섯 lane을 개별 실행해 실제 결과를 얻었다.
  2. :test의 3건 실패는 실재하지만 프로덕션 결함이 아니다. 2026-08-31 HEAD 재실행에서도 283 중 3건이 동일하게 실패한다. 예외 사슬을 직접 뜬 결과 원인은 픽스처가 듀얼스택 localhost를 쓰는 것이었고, 접속 호스트를 127.0.0.1로 바꾸면 세 건 모두 TLS_PERMANENT가 된다(EVD-332). §51이 교정된 진단이다.
  3. 재검증 시점의 소스 드리프트는 0이다. 문서 기준 revision a24ece9c와 HEAD 21234e38 사이에서 src/adapter/outbound/httpclient 변경 파일 수는 0이며, 함께 바뀐 src/build.gradle·modules.json도 이 리프에 영향이 없다(EVD-333).

작업 트리는 변경 0 — 이 분석은 어떤 애플리케이션 코드도 수정하지 않았다.

56. 모듈 완료 조건

  • denominator 370 / 370 FULL_READ, STRUCTURAL_ONLY 0, EXCLUDED 0, 미분류 0 (§52)
  • 7개 하위 범위 전부 §8.1~§8.4 negative-space probe 수행, evidence 167~175 9건 생성
  • 후보 finding 6건을 코드로 추적해 결함 아님으로 판정
  • lane 5종 실행, 실패 3건을 예외 사슬 실측까지 추적해 픽스처 원인으로 확정하고 이전 P1 진단을 철회(§51, EVD-332)
  • 재검증 revision 21234e38에서 리프 소스 변경 0 확인(EVD-333)
  • §0 SSOT identity 블록·고정 골격 대응표·Source anchors 추가
  • 소스 미변경

Source anchors

src/adapter/outbound/httpclient/build.gradle
src/config/architecture/modules.json                        (adapter-outbound-httpclient 항목)
src/build.gradle:477-492                                    (plain-JUnit 조건 — 이 리프는 else 분기)

main/…/apache/ApacheFailureClassifier.java:31-43,45-96,98-106
main/…/api/operation/FailureCategory.java:9-32,34-44
main/…/api/operation/AttemptStage.java:9-42
main/…/resilience/DefaultRetryEligibilityEngine.java:25-51,53-59
main/…/profile/ClientProfileValidator.java
main/…/profile/ClientRuntimeRegistry.java
main/…/api/body/ObjectBody.java
main/…/resilience/AttemptResiliencePipeline.java
main/…/security/SensitiveHeaderStripper.java
main/…/security/TrustedTargetPolicy.java
main/…/dynamic/ValidatedDnsResolver.java
main/…/transport/TransportCapabilityValidator.java
main/…/reactor/BoundedDataBufferFlux.java

test/…/security/MutualTlsHandshakeContractTest.java:38-67,104-126,128-171
testkit/…/testkit/MockHttpServer.java:32-49,60-72
testkit/…/testkit/TlsFixture.java
testkit/…/testkit/TlsMaterials.java

evidence/raw/167-…  ~  175-httpclient-suite-verification.txt
evidence/raw/332-httpclient-dualstack-localhost-masks-tls-permanent.txt
evidence/raw/333-eighteen-docs-source-drift-zero.txt

A12. adapter-outbound-messaging

분석 중에는 12-adapter-outbound-messaging.md 파일이었다. 440줄.

12 · adapter-outbound-messaging

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-outbound-messaging
  • canonical state analysisFile: §A12 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/outbound/messaging · Gradle :adapter:outbound:messaging
  • registry allowed_dependencies: ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"]
  • registry runtime_memberships: ["app-bootstrap"]
  • coverage ledger: FULL_READ 69 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


상태: IN_PROGRESS
revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
경로: src/adapter/outbound/messaging · Gradle: :adapter:outbound:messaging

0. Denominator와 coverage ledger

tracked file 69개 — main 46 (Java 34 / 4,246 LOC + resource 12), test 19 (Java 16 / 3,670 LOC + resource 3), governance 4. 총 약 7.9k LOC.

{ "id": "adapter-outbound-messaging",
  "gradle_path": ":adapter:outbound:messaging",
  "allowed_dependencies": ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"],
  "runtime_memberships": ["app-bootstrap"] }

앞의 두 모듈(cache-redis · httpclient)과 달리 이 leaf는 작고, 무게가 하나의 성질에 몰려 있다 — JSON Schema 검증 런타임을 닫는 것. build.gradle이 그 규율을 세 겹으로 표현한다.

  • configurations.configureEachtools.jackson.dataformat:jackson-dataformat-yaml·org.yaml:snakeyaml·org.snakeyaml:snakeyaml-engine전 configuration에서 제외한다.
  • json-schema-validator:3.0.2에서 jackson-dataformat-yaml을 다시 개별 제외한다.
  • verifyJsonSchemaRuntimeGraph 태스크가 런타임 그래프를 실제로 해석해 YAML 계열과 Jackson 2 core/databind가 없는지, 그리고 잠긴 세 모듈(json-schema-validator:3.0.2·tools.jackson.core:jackson-core:3.0.2·jackson-databind:3.0.2)이 있는지 확인한다. 이 태스크는 check에 붙어 있다.

마지막 주석이 예외를 정직하게 적는다 — "Jackson 3 intentionally retains the 2.x-namespace annotations artifact. It is not a Jackson 2 databind/runtime engine and is part of the official Jackson 3 BOM graph."

그리고 qualification lane 둘이 registerStrictQualificationTest로 등록되며 각각 필수 클래스 목록을 갖는다 — compiled-contract 5종, JSON Schema v1 4종. 둘 다 루트의 :prepareMessagingContractEvidence에 의존하고 JUnit XML을 루트의 evidence 디렉터리로 낸다.

main 패키지 배치(Java 34): kafka 5 · envelope 5 · core 5 · outbox 4 · destination 4 · contract 3 · realtime 2 · config 2 · autoconfigure 2 · 루트 2.

가장 큰 두 파일이 이 leaf의 중심이다 — LocalJsonSchemaRegistry 701줄, DeterministicEnvelopeWriter 548줄.

하위 범위 ledger
# 범위 main test 기타 상태
1 governance + 루트 + autoconfigure + config + 활성화 resource 6 2 6 14 COMPLETE
2 envelope/** + JSON Schema meta 리소스 5 3 13 21 COMPLETE
3 contract/** + destination/** + qualification test 7 6 13 COMPLETE
4 core + kafka + outbox + realtime 16 5 21 COMPLETE
TOTAL 34 16 19 69 4 / 4

manifest: evidence/raw/176-outbound-messaging-module-inventory.txt.


1. Sub-scope 01 범위와 denominator

내부 상태: COMPLETE — 14 / 14 FULL_READ 범위: governance 4 + 루트 2 + autoconfigure 2 + config 2 (main 6) + 활성화 resource 2 + 전용 test 2 역할: 이 leaf가 켜지는 방식과, 켜졌을 때 무엇이 조립되는가

manifest와 probe: evidence/raw/177-outbound-messaging-activation-probes.txt.

2. 스위치와 선택자를 분리한 기록

MessagingBridgeRootAutoConfiguration의 javadoc이 이 sub-scope의 설계 결정이다.

"Before this, whether app.messaging.broker was blank was the de-facto switch. That is a selector doing a switch's job, and it reads badly in both directions: a blank broker with the relay enabled took down startup, while a deployment that wanted no messaging at all still assembled settings, a Kafka adapter configuration and two publishers. The broker id now selects which transport, and this switch decides whether there is one."

지금은 app.messaging.enabled=true가 스위치이고 app.messaging.broker=<id>가 선택자다. MessagingConfig.resolveBroker는 선택자가 설정됐는데 기여 bean이 없으면 명시적 메시지로 startup을 실패시키고(조용한 no-op 아님), settings의 id와 활성 bean의 brokerId()가 다르면 그것도 실패다.

MessagingOffAutoConfigurationImportFilter가 더 흥미롭다. Boot의 Kafka/AMQP auto-configuration은 import metadata로 들어오므로 클라이언트 라이브러리가 classpath에 있기만 하면 connection factory·template·listener container가 만들어진다 — "none of which any project condition was consulted about." 게다가 두 라이브러리가 모두 있으면 "both brokers would assemble at once simply because both libraries are present, which is a different bug the same filter prevents." 필터는 spring.factoriesAutoConfigurationImportFilter로 실제 등록돼 있고(177-... §8.1), 다섯 개 Boot 자동설정 클래스 이름을 명시적으로 막는다.

DisabledMessagePublisherDisabledOutboxMessagePublisher가 별도 클래스인 이유도 README가 적는다 — 한 클래스가 두 포트를 모두 구현하면 getBean(MessagePublisher.class)가 모호해진다.

3. P2 — check에 붙은 verifyJsonSchemaRuntimeGraph가 실행되면 실패한다

이 leaf의 중심 규율은 JSON 검증 런타임을 닫는 것이고, 그것을 강제하는 태스크가 check에 붙어 있다. 실행 probe 결과 그 태스크는 실패한다.

$ ./gradlew :adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph
FAILED
> Messaging JSON runtime is missing required locked module tools.jackson.core:jackson-core:3.0.2
EXIT=1

원인은 태스크가 정확한 패치 버전까지 하드코딩한 데 있다.

[
        'com.networknt:json-schema-validator:3.0.2',
        'tools.jackson.core:jackson-core:3.0.2',
        'tools.jackson.core:jackson-databind:3.0.2'
].each { String required ->
    if (!modules.contains(required)) { throw new GradleException("… missing required locked module ${required}") }
}

그런데 잠긴 실제 좌표는 다르다.

com.networknt:json-schema-validator:3.0.2      ← 일치
tools.jackson.core:jackson-core:3.1.5          ← 3.0.2 아님
tools.jackson.core:jackson-databind:3.1.5      ← 3.0.2 아님
tools.jackson:jackson-bom:3.1.5

Jackson 3 BOM이 3.1.5로 올라가면서 두 좌표가 어긋났고, 태스크는 그것을 "필수 모듈 누락"으로 보고 빌드를 깬다.

판정: P2. 금지 조건 쪽(YAML 계열·Jackson 2 core/databind 부재)은 여전히 옳게 동작하지만, 필수 조건 쪽이 버전 드리프트로 고장 나 있어 게이트 전체가 통과할 수 없다. 결과는 이 저장소가 다른 곳에서 반복해 경계한 바로 그 상태다 — 붙어 있으나 초록일 수 없는 게이트는 사람들이 건너뛰는 법을 배우게 만든다. 수정은 필수 좌표에서 버전을 떼고 group:name만 확인하거나(닫힘 조건은 "무엇이 없는가"이지 "어느 패치인가"가 아니다), 잠금 파일에서 버전을 읽어 비교하는 것이다.

4. P3 — README의 jackson-databind 부재 주장이 현재 상태와 어긋난다

README:36이 손수 짠 JSON 직렬화의 근거를 적는다.

"이 모듈은 jackson-databind 를 classpath 에 두지 않아(스켈레톤을 가볍게 유지) outbox envelope 직렬화는 의존성 없는 손수 짠 JSON 이다."

잠금 파일에는 tools.jackson.core:jackson-databind:3.1.5compileClasspathruntimeClasspath 양쪽에 있고, build.gradle의 검증 태스크는 그 모듈이 있어야 한다고 요구한다(§3). Jackson 2의 com.fasterxml.jackson.core:jackson-databind는 실제로 금지돼 있으므로 서술이 그 네임스페이스를 뜻했다면 맞지만, 문장은 네임스페이스를 한정하지 않는다.

판정: P3. 코드 결함은 아니다 — OutboxEnvelopeJson의 손수 짠 직렬화는 그 자체로 문제가 없다. 기록하는 이유는 그 선택의 근거로 적힌 사실이 더 이상 성립하지 않는다는 점이고, fork가 그 문장을 읽고 "databind가 없다"를 전제로 다른 결정을 내릴 수 있기 때문이다.

5. P3/기록 — 컴파일된 서술자 계열이 production 소비자를 갖지 않는다

이 leaf의 main은 두 반쪽으로 나뉜다.

반쪽 파일 LOC production 소비자
broker/publisher core 5 · outbox 4 · kafka 5 · 루트 2 883 app-bootstrap 5개 파일이 import
컴파일된 계약 envelope 5 · contract 3 · destination 4 · config 2 3,363 (79%) 0

contract·destination·envelope·config 네 패키지를 참조하는 파일은 leaf 밖에 하나도 없다(177-... §8.4c, 매치 0). app-bootstrap이 import하는 것은 core·outbox·kafka·MessagingConfig뿐이다. 그리고 CompiledMessagingDescriptor는 leaf의 main 안에서도 참조가 0이다 — 유일한 소비자가 DestinationBindingCompilerTest다.

이것을 결함으로 올리지 않는 이유가 있다. build.gradle의 qualification lane 둘(messagingCompiledContractsQualificationTest·messagingJsonSchemaV1QualificationTest)이 이 절반을 증거 산출 목적으로 실행하고, JUnit XML을 루트의 build/test-results/messaging-evidence/{compiled,json-schema}로 내며, 루트 build.gradlemessagingVerificationSkeletonsbuild/messaging-evidence/contracts-schema/manifest.json을 요구한다. 즉 이 절반은 애플리케이션에 조립되기 위한 것이 아니라 저장소 수준 readiness 증거를 만들기 위한 것으로 보인다.

기록하는 이유는 그 사실이 어디에도 적혀 있지 않다는 점이다. README는 이 leaf를 "메시징(broker publish + outbox) 아웃바운드 어댑터 모듈"로 소개하고 broker 선택·비활성 sentinel·OutboxEnvelopeJson만 설명한다 — 전체 main LOC의 79%를 차지하는 계약·목적지·봉투·스키마 절반에 대해 한 줄도 없다. fork가 README만 읽으면 이 leaf가 무엇을 담고 있는지 알 수 없다.

6. Negative-space probes — sub-scope 01

  • 8.1 활성화 등록: spring.factories의 import filter와 AutoConfiguration.imports의 루트 자동설정이 실제로 등록됨을 파일 내용으로 확인.
  • 8.2 도달성: 컴파일된 서술자 계열 5종의 leaf-main·app-bootstrap 참조 수 계수 — CompiledMessagingDescriptor 0/0(§5).
  • 8.3 스위치 ↔ 선택자: @ConditionalOnProperty 전수(app.messaging.enabled 1곳, app.messaging.broker=kafka 1곳)와 그 관계 확인(§2).
  • 8.4 실행 probe: verifyJsonSchemaRuntimeGraph를 실행해 실패를 확정하고 잠금 파일과 대조(§3).

7. Sub-scope 01 findings backlog

우선순위 finding reachability
P2 check에 붙은 verifyJsonSchemaRuntimeGraph가 필수 좌표의 패치 버전을 하드코딩해 Jackson 3 BOM 3.1.5 아래에서 항상 실패한다 — 닫힌 런타임 보증이 실제로는 검증되지 않는다 이 모듈의 모든 check
P3 README:36의 "jackson-databind를 classpath에 두지 않는다"가 잠금 파일(tools.jackson.core:jackson-databind:3.1.5, compile+runtime)과 어긋난다 문서
P3/기록 계약·목적지·봉투·스키마 절반(main LOC의 79%)이 production 소비자 0이고, README가 그 절반의 존재와 목적을 전혀 설명하지 않는다 이 leaf를 읽는 fork

8. Sub-scope 01 완료 조건

  • denominator 14 / 14 FULL_READ (177-... OWNED FILES)
  • §8.1~§8.4 probe 수행, 실행 probe 1건으로 게이트 실패 확정
  • 소스 미변경

9. Sub-scope 02 범위와 denominator

내부 상태: COMPLETE — 21 / 21 FULL_READ 범위: envelope/** main 5 (1,573 LOC) + 전용 test 3 (1,140 LOC) + 리소스 13 (핀 고정 메타스키마 10 + test 벡터 3) 역할: 닫힌 Draft 2020-12 스키마 레지스트리와 결정적 봉투 직렬화

manifest와 probe: evidence/raw/178-outbound-messaging-envelope-probes.txt.

10. 레지스트리가 "닫혀 있다"는 것의 의미

LocalJsonSchemaRegistry의 한 줄 요약이 계약이다 — "Immutable, startup-compiled Draft 2020-12 registry backed only by explicitly supplied bytes. Every reference is checked before NetworkNT compilation. After construction this type exposes no loader, URL, file or classpath fetch operation."

닫힘이 네 겹으로 표현된다.

  1. 어휘 allowlistKNOWN_VOCABULARIES 8종(core·applicator·unevaluated·validation·meta-data·format-annotation·format-assertion·content) 밖의 $vocabulary 항목은 거부된다.
  2. 키워드 부분집합$anchor·$dynamicRef·$dynamicAnchor·$recursiveRef·$recursiveAnchor 다섯이 UNSUPPORTED_CLOSED_SUBSET_KEYWORDS문서 어디에서든 거부된다(test rejectsDynamicRecursiveAndAnchorKeywordsEverywhereInTheClosedSubset).
  3. 참조 사전 검사validateAllReferences가 NetworkNT 컴파일 전에 모든 $ref를 확인하고, 원격 참조와 설정된 깊이를 넘는 참조 그래프를 거부한다.
  4. 핀 고정된 메타스키마 권위 — 9개 Draft 2020-12 메타 문서를 리소스로 동봉하고 authority.sha256 매니페스트로 해시를 고정하며, 도메인 분리 상수(ca-skeleton.messaging.draft-2020-12-authority.v1)를 섞는다. 매니페스트는 UTF-8 디코딩을 REPORT 모드로 읽어 잘못된 바이트를 조용히 대체하지 않는다.

실행 probe로 매니페스트를 검증했다 — 동봉된 9개 파일의 SHA-256이 authority.sha256의 아홉 줄과 전부 일치한다(178-... §8.3). 즉 핀이 실제로 현재 파일을 가리킨다.

$id는 정확한 URN 스킴만 허용하고(acceptsOnlyExactUrnSchemeForRootIdentifiersAndAbsoluteReferences), 중첩 $id는 상대·절대 어느 쪽도 허용하지 않으며 값 타입과 무관하게 키 자체를 거부한다(rejectsNestedSchemaIdentifierKeysRegardlessOfValueType).

11. 봉투 작성이 파서를 거치지 않는다

DeterministicEnvelopeWriter는 페이로드를 선언된 shape을 따라 스냅샷한 뒤 그 정확한 바이트를 봉투에 끼워 넣는다 — "those exact trusted bytes are then embedded in the envelope without any raw JSON parser or generator API." embedExactPayload,"payload": 리터럴로 이어 붙이는 방식이다.

입력 검증이 촘촘하다 — draft의 페이로드가 정확히 등록된 final record 클래스여야 하고(exactPayloadClassIsRequiredAndNoAssignableTypeSearchOccurs), contractId와 payloadVersion이 컴파일된 계약과 같아야 하며, 레코드 성분 수·문자열 UTF-8 길이·배열/객체 크기·깊이가 모두 EnvelopeAdmissionLimits로 유계다. 그리고 쓰는 도중에 출력 크기를 본다(boundsJsonOutputDuringWritesInsteadOfOnlyInspectingTheCompletedBuffer).

가변 페이로드 처리도 명시적이다 — snapshotsStatefulMutablePayloadAccessorsOnceAndEmbedsThoseExactBytes. 접근자를 한 번만 부르고 그 바이트를 고정하므로, httpclient의 ObjectBody 문제(같은 키로 다른 바이트)가 여기서는 구조적으로 불가능하다.

12. 적대적 코퍼스가 이 leaf의 test 밀도를 설명한다

test 3파일 1,140줄이 main 1,573줄을 덮고, 이름이 하나씩 구체적인 공격 형태다.

  • 파서 경계: 짝 없는 서로게이트, 비유한 수, 깊이, 숫자 범위 — 검증 전에 거부
  • 스키마 입력: 중복 키, 잘못된 UTF-8, 뒤따르는 쓰레기, 예산 초과 정규식
  • 수 처리: rejectsExtremePositiveDecimalScaleBeforePlainStringAllocation — 큰 scale의 BigDecimal을 평문 문자열로 만들기 전에 거부(메모리 폭발 방지)
  • 컬렉션: checksListSizeBeforeIterationAndFailsClosedOnMutationOrConcurrency
  • 해시: exactEnvelopeHashHasDomainSeparatedGoldenVectorAndDefensiveShaValue — 도메인 분리와 골든 벡터
  • 권위: startupAuthorityDoesNotDependOnARegularNetworkNtCodeSourceJar — 핀 검증이 라이브러리 자신의 jar에 기대지 않음

13. Negative-space probes — sub-scope 02

  • 8.1 닫힘의 실제 강제: 어휘 allowlist·키워드 부분집합·참조 사전 검사의 코드 지점을 각각 확인.
  • 8.2 핀 무결성: authority.sha256의 9줄과 동봉 파일의 실제 SHA-256을 실행으로 대조 — 전부 일치(§10).
  • 8.3 파서 우회: 봉투 작성이 원시 JSON 파서/생성기 API를 쓰지 않고 스냅샷 바이트를 끼워 넣는 경로 확인(§11).
  • 8.4 적대적 커버리지: test 29개 메서드의 이름을 공격 형태별로 분류(§12).

14. Sub-scope 02 findings backlog

우선순위 finding reachability
없음. 어휘·키워드·참조·메타스키마 권위 네 겹이 모두 강제되고, 핀 해시가 실제 파일과 일치하며, 봉투 작성이 파서를 거치지 않고, 적대적 코퍼스가 파서·수·컬렉션·해시 경계를 이름으로 고정한다

15. Sub-scope 02 완료 조건

  • denominator 21 / 21 FULL_READ (178-... OWNED FILES)
  • §8.1~§8.4 probe 수행, 실행 probe 1건으로 핀 매니페스트 무결성 확정
  • 소스 미변경

16. Sub-scope 03 범위와 denominator

내부 상태: COMPLETE — 13 / 13 FULL_READ 범위: contract/** 3 + destination/** 4 (main 7, 1,351 LOC) + 전용 test 6 역할: 통합 이벤트 계약을 컴파일해 닫고, 목적지 바인딩과 파티션 키를 결정적으로 유도한다

manifest와 probe: evidence/raw/179-outbound-messaging-contract-destination-probes.txt.

17. 계약이 컴파일되어 닫힌다

ContractCatalogCompiler정확한 record 타입 토큰으로부터 불변 카탈로그를 만들고, test 이름이 무엇을 거부하는지 전부 적는다 — 중복 stable/schema/payload 신원, 음수 버전, 잘못된 payload kind, null·공백·중복·반사 불일치 성분 순서, 서술자 누락, payload 버전 사이의 logical destination 드리프트.

특히 두 test가 이 계층의 성격을 보여 준다.

  • recursivelyFreezesOnlyTheClosedDeclaredGenericPayloadGraph / rejectsOpenRawWildcardMapJsonTreeInterfaceAndGenericRecordGraphs — 열린 타입(raw·wildcard·Map·JSON 트리·인터페이스·제네릭 record 그래프)을 페이로드로 받지 않는다. 봉투 작성기가 shape을 따라 스냅샷할 수 있으려면 그래프가 닫혀 있어야 한다(§11).
  • snapshotsEveryContributionAccessorExactlyOnceIncludingAStatefulSchemaHash / statefulDescriptorCannotBypassCrossVersionLogicalDestinationDrift — 기여 접근자를 정확히 한 번만 호출한다. 가변 서술자가 검사와 저장 사이에 값을 바꿔 규칙을 우회하는 경로를 닫는다.

compiledContractUsesOnlyAStaticPublicCompositionBridgeWithoutReflectionLeak — 컴파일된 계약이 반사를 밖으로 새게 하지 않는다.

18. 도메인 분리 + 길이 프레이밍이 일곱 곳에서 일관된다

이 leaf의 모든 다이제스트가 같은 형태다 — 버전이 붙은 도메인 상수, \0 구분, 각 필드의 태그와 값을 4바이트 길이로 프레이밍.

상수 위치
ca-skeleton.messaging.contract-catalog-digest.v1 ContractCatalogDigest:16
ca-skeleton.messaging.destination-settings-digest.v1 DestinationBindingCompiler:95
ca-skeleton.messaging.schema-set-digest.v1 DestinationBindingCompiler:121
ca-skeleton.messaging.partition-key.v1 PartitionKeyV1:22
ca-skeleton.messaging.envelope.v1 EnvelopeHashV1:13
ca-skeleton.messaging.schema-set.v1 JsonSchemaIntegrationEventEncoder:25
ca-skeleton.messaging.draft-2020-12-authority.v1 LocalJsonSchemaRegistry:64

카탈로그 다이제스트는 입력 순서와 무관하다(contractId + payloadVersion으로 정렬 후 소화) — test digestIsDeterministicForEmptyAndInputOrderIndependentForNonEmptyCatalogs. 그리고 digestChangesForSchemaHashDescriptorAndCanonicalComponentOrderSemantics가 무엇이 바뀌면 다이제스트가 바뀌어야 하는지를 고정한다.

PartitionKeyV1교차 언어 벡터 진입점을 명시적으로 제공하고("Callers retain ownership of their canonical component grammar; this method never substitutes a missing tenant scope"), 골든 벡터 test 둘이 있다 — 소문자 hex와 정확한 ASCII 바이트, 그리고 nonAsciiAggregateIdGoldenVectorUsesUtf8ByteLengthNotCharacterCount(길이 프레이밍이 문자 수가 아니라 UTF-8 바이트 수임).

DestinationBindingCompiler는 코드 최대치와 배포 최대치의 교집합을 취하고, 배포 쪽이 낮으면 그것이 이기되 양수여야 한다(deploymentMaximumBelowCodeMaximumWinsAndMustRemainPositive).

19. Sub-scope 03 findings backlog

우선순위 finding reachability
없음. 계약 그래프가 닫혀 있고, 기여 접근자가 한 번만 호출되며, 일곱 다이제스트가 도메인 분리와 길이 프레이밍을 일관되게 쓰고 골든 벡터로 고정된다

20. Sub-scope 04 범위와 denominator

내부 상태: COMPLETE — 21 / 21 FULL_READ 범위: core 5 + kafka 5 + outbox 4 + realtime 2 (main 16, 632 LOC) + 전용 test 5 역할: 실제로 조립되는 절반 — broker 추상화, fail-open/fail-closed 두 발행 경로, Kafka seam, 실시간 fan-out

manifest: evidence/raw/176-outbound-messaging-module-inventory.txt의 OWNED FILES 절.

21. 두 발행 경로의 실패 정책이 정반대이고 그 이유가 적혀 있다

포트 정책 근거
MessagePublisherOutboundMessagePublisher fail-open "a broker outage must never turn a core use case into a 5xx (durable delivery is delegated to the outbox/retry path)"
OutboxMessagePublishPortOutboxMessagePublishAdapter fail-closed 실패가 그대로 전파되어 relay가 FAILED/DEAD 전이를 몰 수 있게 한다

OutboundMessagePublisher.publish에 이 저장소에서 반복해 본 종류의 수정 이력이 있다.

"The send and the observation are separate steps because they used to share a try block: a logger that threw after a successful send was caught by the same catch and reported as a publish failure. The broker had accepted the message; the only thing that failed was the record of it, and the two must not be confusable."

그리고 관측 자체가 결과를 바꾸지 못한다 — observeQuietly가 진단 예외를 흡수하며 "Diagnostics are non-authoritative. An appender that is out of disk must not change what the caller believes about the broker."

비활성 sentinel 둘은 조용한 no-op이 아니라 AdapterDisabledException을 던지고, 서로 다른 클래스로 분리된 이유가 bean 조회 모호성이다(§2).

22. BrokerAddress — 정규식을 파서로 바꾼 기록

javadoc이 이전 정규식이 받아들이던 것 넷을 열거한다.

"It ran against the trimmed value but the untrimmed original was what got stored, so " kafka:9092" passed validation and was then handed to the client with its leading space. \d{1,5} accepts 0 and 99999, neither of which is a port. And [^:\s]+ cannot express a bracketed IPv6 literal at all, so [::1]:9092 — the only correct way to write an IPv6 endpoint — was rejected while ::1:9092 was accepted and is ambiguous."

지금은 손수 짠 파서가 대괄호 IPv6를 정확히 다루고(닫는 대괄호 뒤에 :port가 없으면 거부, 빈 host 거부), 포트를 1..65535로 강제하며, 정규화된 형태로 저장한다.

23. Confirmed — 이스케이프 없이 삽입되는 outbox 페이로드는 상류에서 강제된다 (후보 → 결함 아님)

OutboxEnvelopeJson.toJsonevent.payload()이스케이프 없이 그대로 봉투에 넣는다 — "MUST already be a valid serialised JSON value; it is inserted verbatim (no escaping)". 강제되지 않으면 JSON 주입 지점이다.

강제된다. application-coreOutboxEvent 정규 생성자가 OutboxPayloadPolicy.requireValidPayload(payload)를 호출하고, 그 자리 주석이 위험을 그대로 적는다 — "the envelope serialiser inserts this verbatim and unescaped, so an invalid or oversized payload becomes a permanently unparseable message that the relay retries forever." 결함 아님.

24. realtime 두 파일의 자기 한정

MessagingDurableFanoutAdapter는 옆의 ephemeral fan-out과 달리 fail-closed이고, 파티션 키가 채널이 아니라 수신자의 것이며, "Nothing here deduplicates. Delivery is at-least-once by construction and the receiver holds" — 중복 제거 책임이 수신자에게 있음을 명시한다.

RealtimeFanoutEnvelopeJson의 리더는 알 수 없는 필드를 허용하고 없는 필드를 거부한다 — "During a rolling deploy both" 버전이 동시에 쓰므로, 새 필드를 추가한 쪽이 옛 쪽의 항목을 깨지 않게 하는 방향이다(httpclient의 RegistrationCodec과 같은 논리).

25. Negative-space probes — sub-scope 03·04

  • 8.1 다이제스트 일관성: 도메인 분리 상수 7종과 길이 프레이밍 방식을 전수 대조(§18).
  • 8.2 조건부 형제: 같은 broker 위에 놓인 두 발행 경로의 실패 정책이 정반대이고 각각 근거를 가짐(§21). realtime의 durable/ephemeral 쌍도 같은 형태(§24).
  • 8.3 이스케이프 없는 삽입: OutboxEnvelopeJson의 verbatim 삽입을 상류 OutboxEvent 생성자의 강제로 추적해 오탐 판정(§23).
  • 8.4 파서 대 정규식: BrokerAddress가 정규식이 받아들이던 네 가지 비주소를 각각 거부하는지 확인(§22).

26. Sub-scope 03·04 findings backlog

우선순위 finding reachability
P3/기록 OutboxEnvelopeJson의 클래스 javadoc이 README:36과 같은 주장("no Jackson — the module deliberately keeps jackson-databind off its classpath")을 반복하며, 잠금 파일의 tools.jackson.core:jackson-databind:3.1.5와 어긋난다 — §4의 같은 drift가 코드 주석에도 있다 문서

27. Sub-scope 03·04 완료 조건

  • denominator 13 / 13 및 21 / 21 FULL_READ
  • §8.1~§8.4 probe 수행, 조건부 형제 비교 2건
  • 후보 finding 1건(이스케이프 없는 페이로드 삽입)을 상류 강제로 추적해 결함 아님으로 판정(§23)
  • 소스 미변경

28. 모듈 ledger 정합

# 범위 main test 기타 FULL_READ probe
1 governance + 루트 + autoconfigure + config 6 2 6 14 14 176, 177
2 envelope/** + 메타 리소스 5 3 13 21 21 178
3 contract/** + destination/** + qualification 7 6 13 13 179
4 core + kafka + outbox + realtime 16 5 21 21 176
TOTAL 34 16 19 69 69 4 / 4

coverage ledger: FULL_READ 69 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / 미분류 0.

29. 모듈 findings

# 우선순위 finding 위치
1 P2 check에 붙은 verifyJsonSchemaRuntimeGraph가 필수 좌표의 패치 버전(3.0.2)을 하드코딩해 잠긴 Jackson 3 BOM(3.1.5) 아래에서 항상 실패한다 — 닫힌 JSON 런타임 보증이 실제로 검증되지 않는다 §3
2 P3 README:36과 OutboxEnvelopeJson의 클래스 javadoc이 "jackson-databind를 classpath에 두지 않는다"고 적지만 잠금 파일에 tools.jackson.core:jackson-databind:3.1.5가 compile+runtime으로 있다 §4, §26
3 P3/기록 계약·목적지·봉투·스키마 절반(main LOC의 79%)이 production 소비자 0이고 README가 그 존재와 목적(증거 산출)을 전혀 설명하지 않는다 §5

결함 아님으로 판정한 후보 1건OutboxEnvelopeJson의 이스케이프 없는 페이로드 삽입을 OutboxEvent 생성자의 OutboxPayloadPolicy.requireValidPayload로 추적(§23).

30. 이 모듈에서 반복해서 나타난 패턴

  • 스위치와 선택자의 분리. "That is a selector doing a switch's job" — app.messaging.enabled가 여부를, app.messaging.broker가 무엇을 결정한다. 그리고 Boot의 broker 자동설정이 라이브러리 존재만으로 들어오는 경로를 import filter로 막는다.
  • 도메인 분리 + 길이 프레이밍이 예외 없이 일곱 곳. 모든 다이제스트가 버전 붙은 도메인 상수와 4바이트 길이 프레이밍을 쓰고, 골든 벡터로 고정된다.
  • 닫힘을 네 겹으로 표현. 어휘 allowlist, 키워드 부분집합, 참조 사전 검사, 핀 고정 메타스키마 — 그리고 그 핀이 실제 파일과 일치함을 실행으로 확인했다.
  • 관측이 결과를 바꾸지 못한다. observeQuietly와 send/observe 분리 — httpclient의 NoThrowObservationSink와 같은 규칙이 다른 모듈에서 독립적으로 나타난다.
  • 그리고 이 모듈의 P2도 같은 계열이다 — 규율을 강제하려고 만든 게이트가 버전 드리프트로 통과할 수 없게 됐다. httpclient의 §51(test가 잡았는데 고쳐지지 않음)과 같은 방향이다: 검증 장치 자체가 빨간 채로 남아 있다.

31. 검증

evidence/raw/180-outbound-messaging-suite-verification.txt.

$ ./gradlew :adapter:outbound:messaging:test        → BUILD SUCCESSFUL
classes=17 tests=92 failures=0 errors=0 skipped=0

$ ./gradlew :adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph  → FAILED (EXIT=1)
> Messaging JSON runtime is missing required locked module tools.jackson.core:jackson-core:3.0.2

$ git status --short → 0

:test는 92건 전원 통과하고 skip이 0이다. :check는 §3의 게이트에서 실패한다 — 이 모듈 소스의 결함이 아니라 게이트 자신의 버전 하드코딩 때문이다.

qualification lane 둘(messagingCompiledContractsQualificationTest·messagingJsonSchemaV1QualificationTest)은 루트의 :prepareMessagingContractEvidence에 의존하는 저장소 수준 증거 파이프라인의 일부이므로 이 분석에서 실행하지 않았다.

32. 모듈 완료 조건

  • denominator 69 / 69 FULL_READ, STRUCTURAL_ONLY 0, EXCLUDED 0, 미분류 0 (§28)
  • 4개 하위 범위 전부 §8.1~§8.4 negative-space probe 수행, evidence 176~180 5건 생성
  • 실행 probe 2건 — 핀 매니페스트 무결성 확인(통과), verifyJsonSchemaRuntimeGraph 실패 확정
  • 후보 finding 1건을 상류 강제로 추적해 결함 아님으로 판정
  • 소스 미변경

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 21개 (main 19 · test 1 · 기타 1).

src/adapter/outbound/messaging/build.gradle
src/config/architecture/modules.json                    (adapter-outbound-messaging 항목)

main:
  src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingBridgeRootAutoConfiguration.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingOffAutoConfigurationImportFilter.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/config/CompiledMessagingDescriptor.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogCompiler.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/core/DisabledMessagePublisher.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/core/MessagePublisher.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisher.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompiler.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/PartitionKeyV1.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/DeterministicEnvelopeWriter.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdmissionLimits.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/BrokerAddress.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxEnvelopeJson.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/MessagingDurableFanoutAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/RealtimeFanoutEnvelopeJson.java

test:
  src/test/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompilerTest.java

기타:
  src/build.gradle

해석되지 않은 인용 (6종) — 외부 타입·문서상 약칭 등:
  evidence/raw/176-outbound-messaging-module-inventory.txt
  evidence/raw/177-outbound-messaging-activation-probes.txt
  build/messaging-evidence/contracts-schema/manifest.json
  evidence/raw/178-outbound-messaging-envelope-probes.txt
  evidence/raw/179-outbound-messaging-contract-destination-probes.txt
  evidence/raw/180-outbound-messaging-suite-verification.txt


A13. adapter-outbound-notification

분석 중에는 13-adapter-outbound-notification.md 파일이었다. 1,185줄.

13 · adapter-outbound-notification

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-outbound-notification
  • canonical state analysisFile: §A13 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/outbound/notification · Gradle :adapter:outbound:notification
  • registry allowed_dependencies: ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"]
  • registry runtime_memberships: ["app-bootstrap"]
  • coverage ledger: FULL_READ 240 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


상태: IN_PROGRESS
revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
경로: src/adapter/outbound/notification · Gradle: :adapter:outbound:notification

0. Denominator와 coverage ledger

tracked file 240개 — main 171 (14,695 LOC, 리소스 0), test 65 (Java 61 / 10,045 LOC + 템플릿 리소스 4), governance 4. 총 약 24.7k LOC.

{ "id": "adapter-outbound-notification",
  "gradle_path": ":adapter:outbound:notification",
  "allowed_dependencies": ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"],
  "runtime_memberships": ["app-bootstrap"] }

main 패키지 배치(171): platform/dispatch 23 · provider 15 · platform/template 14 · catalog 12 · platform/autoconfigure 11 · platform/provider/{ses 11, webpush 10, fcm 10, twilio 9, smtp 7, http 6, apns 4, webhook 3} 60 · template 7 · platform/security 7 · platform/observation 5 · platform/reactor 3 · slack/webhook 3 · email/google 3 · core 3 · 루트 2.

build.gradle이 앞선 모듈들에서 반복 발견한 drift를 스스로 고친 기록을 담고 있다.

"Jackson's YAML dataformat is excluded because schemas arrive as JSON strings… It does not remove YAML from the runtimeorg.yaml:snakeyaml is on this classpath via spring-boot-starter, which is how Spring Boot reads application.yml. The comment here used to claim the stronger outcome, and the resolved graph had said otherwise for as long as it stood; dependencyPolicy below now states the claim the build can check."

그리고 그 자리에 검사 가능한 선언을 놓았다.

dependencyPolicy {
    absent 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml', because: '…'
    absent 'tools.jackson.dataformat:jackson-dataformat-yaml',        because: '…'
}

바로 앞 모듈(adapter-outbound-messaging)의 P2가 정확히 이 문제 — 주석이 주장하는 것과 해석된 그래프가 다르고, 검사 태스크는 버전을 하드코딩해 통과 불가 — 였다는 점에서, 같은 저장소 안에 문제와 그 해법이 나란히 있다.

또 하나 이 leaf에만 있는 장치가 gradle/notification-api-surface.gradle(NTF-022)이다.

"Nearly every top-level type in the platform is public, which means the boundary between 'the API other code may build on' and 'an implementation detail that happens to be reachable' is not written down anywhere. Enforcing internal-by-default across several hundred types is a design change; pinning the surface is not, and it converts surface growth from something that happens silently into something a reviewer sees. A new public type is then a line in a diff."

공개 타입 목록을 docs/notification/api-surface-snapshot.txt에 고정하고, 변경에는 -PapproveNotificationApiChange가 필요하다.

의존성 주석도 각 선택의 근거를 적는다 — SMTP는 JavaMailSender/MimeMessageHelper 위에 세운다("Rebuilding MIME by hand to avoid one dependency would be the more dangerous choice"), Thymeleaf는 엔진만 받고 Spring starter는 받지 않는다("which would drag a view resolver and a servlet integration onto an outbound adapter that renders strings and never serves a request"), JSON Schema는 messaging 어댑터와 같은 검증기·같은 버전을 쓴다("rather than a second implementation of the same spec").

하위 범위 ledger
# 범위 main test 기타 상태
1 governance + 루트 + platform/autoconfigure 13 5 4 22 COMPLETE
2 catalog/** + template/** — 바인딩 컴파일 · 템플릿 카탈로그 19 4 23 COMPLETE
3 platform/dispatch/** — 스케줄러 · 런타임 · 시도 23 7 30 COMPLETE
4 platform/template/** + platform/security/** 21 11 32 COMPLETE
5 provider + core + platform/{provider,observation,reactor} 29 9 38 COMPLETE
6 platform/provider/* 8종 구현 60 16 76 COMPLETE
7 slack/webhook + email/google + testkit + 템플릿 리소스 6 9 4 19 COMPLETE
TOTAL 171 61 8 240 7 / 7

manifest: evidence/raw/181-notification-module-inventory.txt.


1. Sub-scope 01 범위와 denominator

내부 상태: COMPLETE — 22 / 22 FULL_READ 범위: governance 4 + 루트 2 + platform/autoconfigure 11 (main 13, 1,357 LOC) + 전용 test 5 역할: 이 플랫폼이 켜지는 조건, 무엇을 조립하고 무엇을 거부하는가

manifest와 probe: evidence/raw/182-notification-activation-probes.txt.

2. "이름 없는 상태"를 없애는 것이 이 sub-scope의 주제다

세 클래스가 각각 이전에는 구분되지 않던 두 상황을 구분한다.

NotificationPlatformMode — 공급자가 하나도 조립되지 않은 플랫폼이 공급자가 있는 플랫폼과 똑같이 보였다.

"A platform with no assembled provider used to look identical to one with providers: the same beans, the same scheduler, the same readiness. Requests were accepted durably and then sat in the queue with no eligible route. Naming the state makes it a decision an operator takes rather than a situation they discover."

INGEST_ONLY명시적으로 선택해야 하고("A deployment that reaches zero providers by accident is a misconfiguration, and the whole point of this enum is that the two are told apart"), NotificationProviderAssembly:183이 그것을 강제한다 — 경로가 비었는데 모드가 INGEST_ONLY가 아니면 조립을 거부하고 메시지로 그 모드를 안내한다. app-bootstrap 쪽에서도 NotificationPlatformWorkerConfig가 "everything that starts a thread, and therefore everything INGEST_ONLY must not have"를 그 모드로 가른다.

ProviderType — 설정이 타입을 자유 문자열로 날랐고 "the only thing that read it was a" 비교였다. 지금은 닫힌 enum이라 "the unknown type a binding failure at startup"이고 채널도 타입에서 유도된다.

NotificationSecretRequirements — 이 sub-scope에서 가장 미묘한 판단이다. 이전에는 여덟 개 키를 항상 요구했다.

"That is fail-closed in the wrong direction: it made every deployment provision and rotate keys for capabilities it had switched off — a Web Push signing key for a platform with no Web Push profile… and a key that exists but is never used is a key nobody notices leaking. It also made the eight look equally load-bearing."

지금은 네 개(CONTACT_ENCRYPTION·CONTACT_LOOKUP_HMAC·PAYLOAD_ENCRYPTION·PROVIDER_REQUEST_LOOKUP_HMAC)가 모든 모드에 필요하고 — 수용 경로에 있으므로 INGEST_ONLY에서도 필요하다 — 나머지 넷은 능력을 따라간다. 약해지면 안 되는 방향은 명시된다: "a capability that is switched on and whose key is missing still refuses the boot, because the alternative is discovering it on a user's notification." 그리고 "validation of whatever is supplied happens regardless, so an unused key that is configured is still checked rather than trusted."

SMTP가 PROVIDER_CREDENTIAL을 요구하지 않는 이유도 구체적이다 — 릴레이 주소·사용자·비밀번호가 Spring의 spring.mail.*에서 오므로 "Demanding a provider credential for an SMTP-only deployment asked an operator to invent a secret with nothing to authenticate to."

NotificationProviderAssembly도 같은 종류의 기록을 갖는다 — "This is the step that did not exist. The registry was constructed empty, the route planner…" 그리고 거부 목록을 dispatch worker 시작 전에 적용한다.

3. Confirmed — 이 leaf의 두 검증 태스크는 실제로 통과한다

바로 앞 두 모듈에서 검증 장치가 빨간 채로 남아 있는 것을 발견했으므로(httpclient §51, messaging §3), 이 leaf의 두 태스크를 실행해 확인했다.

$ ./gradlew :adapter:outbound:notification:verifyDependencyPolicy   → BUILD SUCCESSFUL (EXIT=0)
$ ./gradlew verifyNotificationApiSurface                            → OK — 586 public types, unchanged (EXIT=0)

둘 다 통과한다. 그리고 dependencyPolicy가 존재하는 이유가 messaging의 P2와 정확히 같은 문제를 이 leaf가 이미 겪고 고친 결과라는 점이 중요하다.

"It does not remove YAML from the runtime — org.yaml:snakeyaml is on this classpath via spring-boot-starter… The comment here used to claim the stronger outcome, and the resolved graph had said otherwise for as long as it stood; dependencyPolicy below now states the claim the build can check."

즉 "주석이 주장하고 그래프가 반박하는" 상태를 발견하고, 주석을 빌드가 확인할 수 있는 선언으로 바꿨다. messaging 모듈에는 그 교훈이 적용되지 않았고(§ messaging 3·4), 같은 저장소 안에 문제와 해법이 나란히 있다.

verifyNotificationApiSurface는 NTF-022의 장치다 — 공개 타입 586개를 docs/notification/api-surface-snapshot.txt에 고정하고 변경에 -PapproveNotificationApiChange를 요구한다. 그 근거도 정직하다: "Enforcing internal-by-default across several hundred types is a design change; pinning the surface is not, and it converts surface growth from something that happens silently into something a reviewer sees."

4. Negative-space probes — sub-scope 01

  • 8.1 활성화 게이트 전수: @ConditionalOnProperty 2곳(플랫폼 루트, spring.mail.host)과 @ConditionalOnMissingBean 11곳. main 리소스 0 — 이 leaf는 AutoConfiguration.imports를 스스로 갖지 않고 app-bootstrap의 NotificationRootAutoConfiguration이 명시적으로 @Import한다.
  • 8.1b @ConditionalOnBean 제거 기록: SecretMaterialProvider 조건이 있었으나 "Nothing is lost by dropping it. Both configurations are reached only through the same root"으로 제거됨 — 조건 중복 제거.
  • 8.2 이름 붙인 상태의 강제: INGEST_ONLY가 조립(:183)과 worker 배선 양쪽에서 실제로 갈림(§2).
  • 8.3 실행 probe 2건: 의존성 정책과 API 표면 스냅샷 모두 통과(§3).
  • 8.4 비밀 요구 축소: ALWAYS 4종 + 능력별 4종의 분리와 그 방향성 논증 확인(§2).

5. Sub-scope 01 findings backlog

우선순위 finding reachability
없음. 이름 없는 상태 세 가지를 각각 enum·타입·요구집합으로 갈랐고, 두 검증 태스크가 실제로 통과하며, 의존성 주석이 빌드가 확인 가능한 선언으로 대체돼 있다

6. Sub-scope 01 완료 조건

  • denominator 22 / 22 FULL_READ (182-... OWNED FILES)
  • §8.1~§8.4 probe 수행, 실행 probe 2건
  • 소스 미변경

Sub-scope 02 — catalog/** + template/** (23 files, 19 main + 4 test)

내부 상태: COMPLETE — 23 / 23 FULL_READ · 근거 evidence/raw/183-notification-catalog-dispatch-probes.txt

7. 무엇을 하는 코드인가

체크인된 알림 카탈로그(provider · template · route · runtime-profile 4개 목록)를 순수 결정적 컴파일러로 돌려 불변 그래프 하나를 만들고, 그 그래프에서 애플리케이션이 소유한 NotificationFrozenPlan을 뽑아낸다. 그래프에는 SHA-256 manifest digest가 붙어 릴리스 증거가 된다.

digest는 이 저장소가 반복해서 쓰는 형태다 — NotificationCatalogException.update가 문자열을 길이 프레이밍(ByteBuffer.allocate(4).putInt(len) 후 바이트)으로 먹이고, int/long/boolean은 고정폭으로 먹인다(NotificationCatalogException.java:48-66). 인접 필드 연결로 인한 충돌이 구조적으로 불가능하다.

LocalEmailRenderer(473 LOC)는 이 leaf에서 가장 밀도 높은 보안 코드다. 사슬 전체를 확인했다:

  1. 정적 HTML 허용목록 — 태그 27종(a b body br div em h1..h6 head html i img li ol p span strong table tbody td th thead tr ul), 무해 속성 3종(alt, aria-label, title), href<a>에서만 그리고 https://로만 (LocalEmailRenderer.java:20-26)
  2. 토큰 문법\{\{([a-z][A-Za-z0-9]{0,63})\|([a-z_]+)}} — 이름과 컨텍스트를 함께 요구
  3. 위치 검증html_text는 태그 바깥에서만, html_attr는 승인된 무해 속성 안에서만, url_component는 승인된 HTTPS 링크 안에서만 (:182-198)
  4. 치환 시점 컨텍스트별 이스케이프text는 원문, html_texthtml(), html_attrhtmlAttribute(), url_componenturlComponent() + SafeText 타입 요구 (:231-241)
  5. Matcher.quoteReplacement (:243), 미해결 {{ 거부 (:247), header 컨텍스트 CR/LF 거부 (:62, :227)

선언한 컨텍스트와 실제 삽입 위치를 둘 다 검사한다는 점이 핵심이다. 대부분의 템플릿 엔진은 둘 중 하나만 한다.

템플릿 자산은 체크섬으로 고정된다: NotificationTemplateCatalog가 manifest가 지명한 classpath 자산만 로드하고 정확한 체크섬을 대조하며(:52-54), 렌더러는 계획에 얼어붙은 체크섬과 렌더러 리비전이 manifest와 일치하는지 다시 확인한다(LocalEmailRenderer.java:93-96). 계획 시점과 렌더 시점 사이에 템플릿이 바뀌면 렌더가 실패한다.

8. Negative-space probes — sub-scope 02

  • 8.1 도달성 (P2 발견)NotificationBindingCompiler.validateRouteShape의 전략 분기. 아래 §9.
  • 8.2 조건 형제 비교 (P2 발견) — 같은 곳. 테스트가 다른 가드에 걸려 통과한다.
  • 8.3 중복 메커니즘 — digest 계산이 bindingDigest · manifestDigest · NotificationCutoverRouteCatalog.digest 세 곳에 있으나 모두 같은 길이프레이밍 헬퍼를 경유하고 필드 집합이 다르다. 중복 아님.
  • 8.4 카운트 드리프트INITIAL_CARD_IDS 3종과 APPROVED_CARD_PROVIDERS 3항목이 일치하며, 카드는 NotificationProviderCapabilityCard.initial(cardId)값 동등성까지 대조된다(:284-290). 체크인된 카드가 승인된 정의에서 한 필드라도 벗어나면 컴파일이 실패한다. 드리프트 불가.

9. Sub-scope 02 findings

P2 — SINGLE 전용 가드가 먼저 던져 다중 타깃 검증 전체가 도달 불가이고, 그것을 검증한다는 테스트는 다른 가드에 걸려 통과한다

validateRouteShape같은 메서드 안에서 두 번 전략을 본다:

// NotificationBindingCompiler.java:141-145
if (route.routeStrategy() != NotificationRouteStrategy.SINGLE) {
  throw new NotificationCatalogException(
      "initial notification catalog supports SINGLE route strategy only");
}
...
// :159-184
switch (route.routeStrategy()) {
  case SINGLE -> { ... }
  case FAN_OUT_ALL -> { ... }          // 도달 불가
  case ORDERED_FALLBACK -> { ... }     // 도달 불가
  default -> throw ...                 // 도달 불가
}
validateFallbackGraph(route);

:141이 통과시킨 값은 SINGLE뿐이므로 :168·:174·:181 세 분기는 어떤 입력으로도 실행되지 않는다. 이어서 SINGLE 분기(:161-166)가 maximumTargets() == 1이고 fallbackTargetId()가 하나도 없을 것을 요구하므로, validateFallbackGraph(:188-218)의 edges 맵은 항상 비어 있다. 따라서:

  • "fallback references unknown target" (:204) — 도달 불가
  • "cyclic notification fallback graph" (:213) — 도달 불가

여기까지는 "초기 카탈로그는 SINGLE만"이라는 명시적 선언에 딸린 전방 스캐폴딩으로 볼 수 있다. 문제는 테스트가 그렇게 읽지 않는다는 점이다.

// NotificationBindingCompilerTest.java:279-307  (테스트명: legacyReceiptUnsafeFallbackBoundsAndCyclesAreRejected)
NotificationRouteDescriptor cyclic = new NotificationRouteDescriptor(
    ..., NotificationRouteStrategy.ORDERED_FALLBACK, ...,
    List.of(new Target("target-a", "slack-runtime-r1", Optional.of("target-b")),
            new Target("target-b", "slack-runtime-r1", Optional.of("target-a"))));
assertThatThrownBy(() -> new NotificationBindingCompiler(...).compile())
    .isInstanceOf(NotificationCatalogException.class)
    .hasMessageContaining("strategy");

a → b → a 순환을 만들고 "순환이 거부된다"는 이름을 붙였다. 그러나 실제로 던져지는 것은 :143"initial notification catalog supports SINGLE route strategy only"이고, 이 문자열은 "strategy"포함한다. 단언이 통과한다. 순환 탐지기는 한 번도 실행되지 않는다.

바로 위의 unsupportedFanOut 케이스(:246-277)도 같은 문자열을 단언하므로, 이름이 약속한 두 성질(bounds, cycles) 중 검증되는 것은 "SINGLE이 아닌 전략은 거부된다" 하나이며 그것을 두 번 확인한다.

실패 시나리오 — 누군가 ORDERED_FALLBACK을 열려고 :141-145의 가드를 제거한다. 그것이 이 기능을 켜는 자연스러운 한 줄이고, 테스트 스위트는 초록색을 유지한다(단언은 hasMessageContaining("strategy")이고, 가드가 없어지면 :178"ORDERED_FALLBACK route has invalid bounds":233"provider route strategy does not match route"가 대신 걸려 여전히 "strategy"를 포함한다). 순환 fallback 카탈로그가 통과할지 여부는 그날 처음 알게 된다.

권고 — 단언을 메시지 전문 또는 고유 부분문자열("cyclic", "invalid bounds")로 좁힌다. "strategy"는 이 컴파일러의 서로 다른 네 개 예외 메시지에 등장하는 부분문자열이라 어떤 가드가 걸렸는지 식별하지 못한다.

P3/기록 — NotificationPlanAdapter가 이미 정렬된 리스트를 타깃마다 다시 정렬한 뒤 indexOf로 순번을 구한다
// NotificationPlanAdapter.java:196-210
binding.targets().stream()
    .sorted(Comparator.comparing(target -> target.target().targetId()))
    .map(target -> new FrozenTarget(
        binding.targets().stream()
            .sorted(Comparator.comparing(item -> item.target().targetId()))
            .toList()
            .indexOf(target),          // ← 타깃마다 재정렬 + 선형 탐색
        ...))

NotificationRouteDescriptor의 표준 생성자가 이미 targetId 기준 정렬(:68)과 유일성(:73)을 보장한다. 현재 maximumTargets == 1이므로 실행 비용은 없고 결과도 옳다. FAN_OUT_ALL을 열 때(위 P2와 같은 변경) 타깃 수만큼 제곱으로 늘어나며, 순번은 스트림 위치가 아니라 equals 일치 위치로 정해진다. 기록만 남긴다.

10. Sub-scope 02 완료 조건

  • denominator 23 / 23 FULL_READ
  • §8.1~§8.4 수행 — 도달성에서 P2 1건, 기록 1건
  • 소스 미변경

Sub-scope 03 — platform/dispatch/** (30 files, 23 main + 7 test)

내부 상태: COMPLETE — 30 / 30 FULL_READ · 근거 evidence/raw/183-notification-catalog-dispatch-probes.txt

11. 무엇을 하는 코드인가

배달의 실행 평면이다. 리스를 claim하는 스케줄러, 자격증명 세대를 들고 있는 provider 런타임 레지스트리, 시도 단위 permit, 그리고 세 개의 배경 pass(회수 · 이벤트 재생 · 재대사).

이 패키지의 javadoc은 이 저장소에서 가장 많이 "과거에 무엇이 틀렸는지"를 적어 둔 곳이다. 표본:

  • NotificationSchedulerWorker.runOnce:71-74 — "배치를 통째로 claim한 뒤 세마포어 뒤에 줄 세웠기 때문에, 동시성 한도보다 큰 배치는 아무도 작업하지 않는 배달에 리스를 걸고 있었다. 짧은 리스에서는 차례가 오기 전에 만료되어 다른 워커가 같은 배달을 가져갔다." → 지금은 min(claimBatchSize, availablePermits)만 claim한다.
  • NotificationSchedulerWorker.runOnce:80-83 — "이 게이지는 claimed.size()를 보고했는데 그것은 배치 크기로 상한이 잡힌다. 큐 10과 큐 1,000만이 같은 숫자를 발행했다 — '큐 깊이'라는 이름의 유일한 지표가 큐가 자라는 것을 보여줄 수 없는 지표였다." → 지금은 servingState.currentState().backlogDepth().
  • ProviderAttemptLimiter.window:224-231 — 창 시작과 카운트가 두 개의 atomic이었다. "한 스레드가 초 경계를 넘으며 창을 CAS한 뒤 별도 연산으로 카운트를 리셋했으므로, 그 사이 다른 스레드의 증가는 전부 버려졌다 — 트래픽이 롤오버하는 바로 그 순간에 설정보다 많이 통과시켰다." → 지금은 AtomicReference<RateWindow> 하나에 accumulateAndGet.
  • ProviderRuntime.LimiterPermit:238-244 — "close가 무조건 release했다. 두 번 닫힌 permit은 한 번의 획득에 두 슬롯을 반납했고, Semaphore는 가져간 것보다 많이 반납하면 자란다. 동시성 상한이 설정보다 영구히 높아진다 — 조용히, provider를 과부하시키는 방향으로." → 지금은 AtomicBoolean CAS.
  • ProviderRuntimeRegistry.draining:21-24CopyOnWriteArrayList를 락 밖에서 변형했다. → 지금은 compute 안에서 리스트 전체를 교체.
  • ProviderRuntimeRegistry.replace:66-69 — 교체·드레인·등록이 세 단계로 나뉘어 있어 두 로테이션이 인터리브하면 더 오래된 세대가 current로 남을 수 있었다. → 지금은 current.compute 한 연산.
  • NotificationSchedulerWorker.close:167-170 — "폴링 스레드를 시작하고 잊었다. close()가 executor를 내리고 반환하는 동안 루프는 여전히 다음 배치를 claim할 수 있었으므로, 이미 사라진 프로세스가 리스를 쥔 채로 종료될 수 있었다." → 지금은 interrupt + join.
  • NotificationBackgroundWorkers:159-163 — 아래 §12.3.

12. Negative-space probes — sub-scope 03

12.1 (8.1) 도달성 — 배경 작업자 배선

이 저장소에서 반복 발견되는 "구현했으나 호출자가 없는 절반" 패턴을, 이 패키지는 스스로 찾아 고쳤고 그 사실을 적어 두었다:

NotificationBackgroundWorkers:159-163 — "둘 다 호출자 없는 클래스로 존재했다. LeaseRecoveryService는 죽은 워커가 남긴 in-flight 배달을 회수하려고 작성되었으나 한 번도 스케줄되지 않았다; 원장의 재생 질의는 persistence에 구현되어 한 번도 읽히지 않았다. 디스패치 스케줄러의 오류 경로는 회수가 존재한다고 가정하고 있었다 — 낙관적으로 리스를 해제하는 대신 만료되게 두는데, 회수가 결정할 것이라는 근거에서다 — 그래서 그 부재가 의도된 설계를 누수로 바꿨다."

ProviderEventReplayWorker:5-11도 같은 종류를 적는다: pendingProjectionunmatched 두 질의가 persistence에 구현되어 있었고 둘 다 호출자가 없었다. "콜백 경로가 그 재생에 의존한다. 제출 트랜잭션이 커밋되기 전에 콜백을 보내는 provider는 정상이고 수집 경로는 그런 이벤트를 버리지 않고 PENDING으로 저장한다. 재생하는 것이 없으면 '나중에 매칭한다'는 주석 안에서만 참이었다."

ReconciliationJobWorker:5-11도 같다: 재대사는 리스가 만료되어 회수가 미완 시도를 지나칠 때만 실행됐다. "제출이 애매한 채 워커가 깨끗하게 종료한 흔한 경우는 다시 물어보는 일이 없었다. 배달은 무기한 RECONCILIATION_REQUIRED로 남았고, 이는 결과를 아무도 모르는 상태가 아니라 큐가 멈춘 것처럼 읽힌다."

실행 확인 — 세 작업자가 실제로 배선되어 있는가:

$ git grep -rn 'NotificationBackgroundWorkers|LeaseRecoveryService|ProviderEventReplayWorker|ReconciliationJobWorker' -- adapter/outbound/notification/src/main app-bootstrap/src/main | grep -v platform/dispatch/
app-bootstrap/.../NotificationPlatformWorkerConfig.java:57:  public ReconciliationJobWorker reconciliationJobWorker(
app-bootstrap/.../NotificationPlatformWorkerConfig.java:90:  public LeaseRecoveryService notificationLeaseRecoveryService(
app-bootstrap/.../NotificationPlatformWorkerConfig.java:119: public ProviderEventReplayWorker notificationProviderEventReplayWorker(
exit=0

세 개 모두 app-bootstrap에 빈으로 등록되어 있고 NotificationBackgroundWorkers.start()가 셋을 각각 scheduleWithFixedDelay한다(:220-222). 배선 확인됨. cache-redis의 RedisStartupProbe(호출자 0)나 messaging의 contract/**(외부 소비자 0)와 대조된다 — 같은 저장소가 같은 함정을 한쪽에서는 메우고 한쪽에서는 남겨두었다.

12.2 (8.2) 조건 형제 비교 — 상태 전이 행렬

ProviderRuntime의 여섯 전이를 형제로 놓고 비교하면 두 종류로 갈린다:

전이 형태 현재 상태를 보는가
markAuthenticationFailed health.set(...) 아니오 (무조건)
markThrottled updateAndGet(HEALTHY이면 전이)
markDegraded updateAndGet(HEALTHY이면 전이)
markHealthy updateAndGet(THROTTLED·DEGRADED만)
resumeHealthy updateAndGet(AUTHENTICATION_FAILED이면 거부)
markDraining updateAndGet(current -> new RuntimeHealth(DRAINING, ...)) 아니오
markDisabled updateAndGet(current -> new RuntimeHealth(DISABLED, ...)) 아니오

마지막 두 개만 current를 받아 놓고 쓰지 않는다. 이것이 §13의 P2다.

12.3 (8.3) 중복 메커니즘 — 종료 경로

NotificationSchedulerWorker.closeNotificationBackgroundWorkers.close 둘 다 "취소 → shutdown → awaitTermination(grace) → shutdownNow"를 수행한다. 형태는 같지만 대상이 다르다(폴링 스레드 + virtual-thread executor vs. 단일 데몬 scheduler). 중복 아님.

한 가지 기록: 스케줄러의 close()는 폴링 스레드를 interrupt()하지만(:173), runOnceglobalConcurrency.acquireUninterruptibly()(:90)는 인터럽트에 반응하지 않는다. 주석(:169)은 "인터럽트가 poll-interval sleep을 깬다"고만 말하고 그 점은 정확하다. 세마포어는 in-flight 작업이 finally에서 반납하므로 결국 풀리고, 최악의 경우 join(shutdownGrace)가 만료된 뒤 종료가 계속된다. 결함 아님.

12.4 (8.4) 문서/카운트 드리프트

RuntimeDrainCoordinator의 javadoc은 "deadline은 주입된 clock이 아니라 System.nanoTime()에서 온다"고 주장한다. 코드 :172가 정확히 그렇다. deadlineNanos 비교도 오버플로 안전한 System.nanoTime() - deadlineNanos >= 0 형태다(:174). 드리프트 없음.

LeaseRecoveryService의 javadoc은 두 경우(시도 행 없음 → requeue, 시도 행 있고 미완 → reconcile)를 약속하는데 코드는 세 경우를 구현한다 — 세 번째는 provablyNeverStarted(attempt)(:94-100)로, 지속된 증거의 확실성이 PROVEN이고 값이 false일 때 reconcile 없이 requeue한다. 코드 주석(:126-129)이 그 경우를 설명하지만 클래스 javadoc의 <ul>은 두 항목뿐이다. 문서가 코드보다 보수적인 방향의 드리프트라 위험을 만들지 않는다. P3/기록.

13. Sub-scope 03 findings

P2 — AUTHENTICATION_FAILED를 지우지 않는다는 resumeHealthy의 보장이, 관리자 평면에 노출된 2단계 시퀀스로 우회된다

ProviderRuntime.resumeHealthy는 자신이 지키는 성질을 javadoc에 명시한다:

"It still refuses AUTHENTICATION_FAILED, because declaring a provider healthy does not give it a credential the provider will accept — the caller is told so rather than being handed a runtime that will fail on its first attempt."

그러나 markDraining(과 markDisabled)은 현재 상태를 읽지 않는다:

// ProviderRuntime.java  markDraining()
return health.updateAndGet(
        current -> new RuntimeHealth(ProviderRuntimeState.DRAINING, Optional.of("DRAINING")))
    .state() == ProviderRuntimeState.DRAINING;

current는 람다 파라미터로 받기만 하고 쓰이지 않는다. 그리고 두 전이 모두 관리자 포트에 노출되어 있다:

// RegistryProviderRuntimeControl.java:30-39   (implements ProviderRuntimeControlPort)
return switch (desiredState) {
  case DISABLED -> runtime.markDisabled();
  case DRAINING -> runtime.markDraining();
  case HEALTHY  -> runtime.resumeHealthy();
  ...

실패 시나리오

  1. provider가 자격증명을 거부 → applyHealthmarkAuthenticationFailed("INVALID_CREDENTIAL") (RegistryProviderDispatchGateway.java:251-252). 상태 AUTHENTICATION_FAILED, admitsNewAttempts() = false(ProviderRuntimeState.java:19). 라우트가 한 번 열리고 대기 중인 알림들이 재시도를 만들지 않는다 — 클래스가 의도한 동작.
  2. 운영자가 setState(profile, HEALTHY, ...)resumeHealthy()false 반환, 상태 유지. 가드가 작동한다.
  3. 운영자가 setState(profile, DRAINING, ...)markDraining() → 무조건 덮어쓰기. 상태 DRAINING, reason "DRAINING". "INVALID_CREDENTIAL"이라는 진단이 사라진다.
  4. 운영자가 다시 setState(profile, HEALTHY, ...) → 현재 상태가 AUTHENTICATION_FAILED가 아니므로 → HEALTHY, reason 없음.

결과: provider가 거부한 자격증명을 그대로 든 런타임이 admitsNewAttempts() = true가 되어 큐를 다시 받는다. 첫 시도가 실패하면 applyHealth가 다시 AUTHENTICATION_FAILED로 돌려놓으므로 상태는 자가 복구되지만, (a) 시도 하나가 provider를 향해 실제로 나가고, (b) 그 시도 이전에 RegistryProviderDispatchGateway javadoc이 막으려 한 "만료된 키 하나가 큐에 쌓인 알림 수만큼 재시도를 만드는" 창이 매 리셋마다 다시 열리며, (c) 운영자가 필요로 하는 원인 코드가 3단계에서 이미 파괴되어 있다.

테스트가 이것을 잡지 못하는 이유 — 해당 테스트는 세 전이를 각각 새 런타임에서 확인하고 합성을 확인하지 않는다:

// ProviderRuntimeStateTest.java:125-140
ProviderRuntime drained = runtime(1, 1);   drained.markDraining();
assertThat(drained.resumeHealthy()).isTrue();

ProviderRuntime disabled = runtime(1, 1);  disabled.markDisabled();
assertThat(disabled.resumeHealthy()).isTrue();

ProviderRuntime failed = runtime(1, 1);    failed.markAuthenticationFailed("INVALID_CREDENTIAL");
assertThat(failed.resumeHealthy())
    .as("declaring it healthy does not give it a credential the provider accepts")
    .isFalse();

.as(...) 설명문이 서술하는 성질이 정확히 합성으로 깨지는 성질이다. failed.markDraining(); assertThat(failed.resumeHealthy()) 한 줄이면 드러난다.

권고markDraining/markDisabledAUTHENTICATION_FAILED를 만나면 그 사실을 보존하도록 한다(예: 상태는 DRAINING으로 바꾸되 reason은 유지하고, resumeHealthy는 마지막으로 관측된 자격증명 거부가 로테이션으로 대체되지 않은 한 거부). 최소한으로는, 로테이션 경로(ProviderRuntimeRegistry.replace:90)만 무조건 드레인을 쓰고 관리자 경로는 조건부 드레인을 쓰도록 갈라야 한다 — 전자는 새 세대가 이미 설치된 뒤라 자격증명 이력이 무의미하지만, 후자는 같은 세대를 계속 쓴다.

P3/기록 — LeaseRecoveryService javadoc의 경우 목록이 2개, 코드는 3개

§12.4. 문서가 코드보다 보수적이라 위험은 없다.

14. Sub-scope 03 완료 조건

  • denominator 30 / 30 FULL_READ
  • §8.1~§8.4 수행 — 배선 실행 확인 1건, 상태 행렬 비교에서 P2 1건, 기록 1건
  • 소스 미변경

Sub-scope 04 — platform/template/** + platform/security/** (32 files, 21 main + 11 test)

내부 상태: COMPLETE — 32 / 32 FULL_READ · 근거 evidence/raw/184-notification-template-security-probes.txt

15. 무엇을 하는 코드인가

두 패키지가 알림 어댑터의 보안 표면 전체를 담당한다.

platform/security — 저장 시 암호화와 키 사용처 분리. SecretPurpose 8종이 각각 정확히 하나의 소유자를 가진다(probe §8.4):

purpose 소유자
CONTACT_ENCRYPTION · CONTACT_LOOKUP_HMAC AesGcmContactPointProtector
PAYLOAD_ENCRYPTION AesGcmNotificationPayloadProtection
CALLBACK_FINGERPRINT_HMAC AesGcmCallbackPayloadProtection
PROVIDER_REQUEST_LOOKUP_HMAC HmacProviderRequestIdHasher
PROVIDER_CREDENTIAL ProviderCredentialManager
CALLBACK_SIGNING TwilioCallbackAdapter · WebhookNotificationProviderAdapter
VAPID_SIGNING VapidKeyRegistry

NotificationSecretRequirements가 8종을 모두 열거하므로(§sub-scope 01) 조립 시점에 부재가 드러난다.

AesGcmContactPointProtector두 키를 요구하고 그 이유를 정확히 말한다: 암호문은 비결정적이어야 하고(같은 주소의 두 레코드가 눈에 띄게 동일하면 안 됨) 동등성 조회는 결정적이어야 하는데, "those are opposite requirements, and one key cannot serve both without leaking one of them." 그리고 두 키가 실제로 다른지 런타임에 확인한다 — keyId 비교와 MessageDigest.isEqual로 자료 비교까지(:115-120). 지문이 keyless digest가 아니라 HMAC인 이유도 적혀 있다: "an unkeyed hash of a phone number is recoverable by enumeration in seconds."

AAD에 접촉점 종류를 묶어(:122-126) 암호문을 다른 종류로 옮기면 태그 검증이 깨진다. 키 길이는 256비트로 강제된다(:108-111).

parseWebPush(:201-219)의 javadoc은 이 저장소에서 반복되는 자기고발 중 가장 날카로운 축이다:

"이 메서드는 auth secret으로 16개의 0바이트를, VAPID key id로 문자열 "restored"를 반환하곤 했다 — 둘 다 각자의 암호화 컬럼에 산다는 근거에서였다. 그 컬럼들은 존재하지 않는다 — 마이그레이션에도, 엔티티에도. 그래서 구독은 브라우저가 복호화할 수 있는 RFC 8291 페이로드를 만들 수 없는 상태로, 어떤 VAPID 키가 서명했는지 말할 수 없는 상태로 저장소에서 돌아왔다."

platform/template — 렌더링과 이스케이프. TemplateSlotMode가 목적지를 이름 붙이고(TEXT/SUBJECT/HTML_TEXT/URI), TemplateSlotPolicy가 모드별 규칙을 두 엔진이 공유하는 한 곳에 둔다. 이것도 자기고발이 붙어 있다: 규칙이 PlaceholderTemplateEngine의 private 헬퍼였고 "the other engine had no equivalent — it did not override the mode-aware render at all, so selecting it dropped every slot to the unescaped path."

NotificationTemplateEngine의 mode-aware renderdefault 메서드가 아니라 abstract인 이유도 같은 사건에서 나온다: "A default that discards its own argument is not a fallback; it is the rule not applying, and the engine that skipped it looked complete because the interface compiled."

16. Negative-space probes — sub-scope 04

  • 8.1 도달성AccessContext의 필드를 읽는 코드. 아래 §17.1.
  • 8.2 조건 형제 비교ThymeleafStringTemplateEngine의 두 render 오버로드. 아래 §17.2.
  • 8.3 중복 메커니즘escapeverifyRendered는 같은 4개 모드를 다루지만 목적이 다르다(값 단위 이스케이프 vs. 완성된 슬롯 검증). HTML_TEXT에서만 갈리며(escape는 이스케이프, verifyRendered는 통과) 그 근거가 적혀 있다 — "asking it to render HTML and then escaping the result would escape the operator's markup too." 중복 아님.
  • 8.4 키 분리 — 8종 purpose, 소유자 1:1, 조립 시점 요구 선언 존재. 드리프트 없음.

17. Sub-scope 04 findings

17.1 P2 — "모든 reveal은 감사된다"고 선언한 AccessContext를 읽는 코드가 저장소에 하나도 없다

타입과 포트가 이 성질을 두 번 선언한다:

// AccessContext.java:5
/** Why a plaintext contact point is being revealed. Every reveal is auditable. */
public record AccessContext(String purposeCode, String actorRef, boolean auditRequired) { ... }

// ContactPointProtector.java:11
/** Decrypt a value for an audited purpose. */
ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context);

유일한 구현은 컨텍스트를 null 검사만 하고 버린다:

// AesGcmContactPointProtector.java:86-97
public ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context) {
  Objects.requireNonNull(protectedValue, "protectedValue");
  Objects.requireNonNull(context, "context");     // ← context를 언급하는 유일한 줄
  SecretKeyMaterial key = keys.keyById(protectedValue.keyId());
  byte[] plaintext = decrypt(key, protectedValue.nonce(), associatedData(...), ...);
  return parse(protectedValue.type(), new String(plaintext, StandardCharsets.UTF_8));
}

grep -n 'context'가 이 파일에서 잡는 줄은 :86(시그니처)과 :88(null 검사) 두 개뿐이다(probe §8.1).

저장소 전체 접근자 카운트:

$ grep -rn '\.auditRequired()' --include=*.java . | wc -l   → 0
$ grep -rn '\.purposeCode()'  --include=*.java . | wc -l   → 0

actorRef()는 7건 잡히지만 전부 다른 타입(AdminActor, NotificationAuditEvent)의 동명 접근자다. 즉 AccessContext세 필드 모두 어디에서도 읽히지 않는다. 레코드는 생성만 되고 소비되지 않는다.

감사 설비가 없어서가 아니다. NotificationAuditPort가 있고 LoggingNotificationAudit이 전용 로거 notification.audit으로 구현하며, 같은 leaf의 ProviderRuntimeRotator:26가 실제로 주입받아 쓴다. 관리자 평면도 쓴다. 복호화 경로만 쓰지 않는다.

실패 시나리오 — 사고 조사에서 "누가, 어떤 목적으로, 어떤 접촉점의 평문을 열었는가"를 묻는다. 프로덕션 reveal 호출자는 6곳(SES · SMTP · Twilio · APNs · FCM · Web Push 어댑터)이고 전부 AccessContext.dispatch(profileId)를 넘긴다 — 이 팩토리는 auditRequired = false로 고정한다(AccessContext.java:18). 그러나 true를 넘겨도 결과는 같다: 그 필드를 보는 코드가 없다. 이메일 주소·전화번호·디바이스 토큰의 평문 복호화가 어떤 흔적도 남기지 않으며, 타입 시그니처는 남는 것처럼 읽힌다.

호출자가 컨텍스트를 정직하게 채우고 있다는 점이 이 발견을 더 뾰족하게 만든다 — 배선은 다 되어 있고 종단만 비어 있다. cache-redis의 RedisStartupProbe(호출자 0)와 messaging의 verifyJsonSchemaRuntimeGraph(통과 불가)와 함께 이 저장소의 세 번째 "장치는 있고 회로가 닫히지 않은" 사례다.

권고AesGcmContactPointProtectorNotificationAuditPort를 주입하고 reveal에서 record(...)한다. 이벤트에는 purposeCode·actorRef·keyId·type만 넣고 평문과 지문은 넣지 않는다(NotificationAuditPort javadoc: "never carrying raw addresses"). auditRequired가 false인 dispatch 경로를 샘플링할지 여부는 그 다음 결정이다 — 지금은 필드가 정책을 표현하지만 정책을 집행하는 코드가 없다.

17.2 P2 — Thymeleaf 예외 메시지 삭제 가드가 프로덕션이 타지 않는 오버로드에만 있다

ThymeleafStringTemplateEngine은 같은 클래스에 render 두 개를 갖는다. 모드 없는 쪽은 예외를 잡아 메시지를 버리고, 그 이유를 명시한다:

// ThymeleafStringTemplateEngine.java:84-100   (mode-less overload)
public String render(String source, Map<String, Object> variables) {
  ...
  try {
    return engine.process(source, contextFor(source, variables));
  } catch (RuntimeException failure) {
    // The message is dropped on purpose. Thymeleaf reports the offending expression, and a
    // template expression contains the variable it failed on — which for this platform is a
    // one-time code or a recipient name.
    throw new TemplateRenderingException(NotificationFailureDescriptor.preDispatch(...));
  }
}

모드 있는 쪽은 같은 호출을 감싸지 않는다:

// :67-82   (mode-aware overload)
public String render(TemplateSlotMode mode, String source, Map<String, Object> variables) {
  Objects.requireNonNull(mode, "mode");
  String rendered = engineFor(mode).process(source, contextFor(source, variables));  // try 없음
  return TemplateSlotPolicy.verifyRendered(mode, rendered);
}

probe로 확인한 catch 위치는 :92 하나뿐이고 process( 호출은 :80:91 두 곳이다.

프로덕션은 모드 있는 쪽만 탄다. 유일한 렌더러 CanonicalNotificationRenderer가 모든 슬롯을 모드와 함께 부른다:

$ grep -rn 'engine.render(' .../CanonicalNotificationRenderer.java
125:    return engine.render(modeOf(slot), template.content().requireSlot(slot), variables);
133:        .map(source -> engine.render(modeOf(slot), source, variables));

render(String, Map)은 main 소스 전체에서 선언 3곳(인터페이스 + 두 구현)만 있고 호출자가 없다.

그리고 이 엔진은 실제로 배선된다:

// NotificationPlatformAutoConfiguration.java:74-75
case "placeholder" -> new PlaceholderTemplateEngine();
case "thymeleaf"   -> new ThymeleafStringTemplateEngine();
// NotificationPlatformProviderConfig.java:73-75  (app-bootstrap)
return engine instanceof ThymeleafStringTemplateEngine thymeleaf
    ? new ThymeleafNotificationRenderer(channel, templates, validator, thymeleaf)
    : new CanonicalNotificationRenderer(channel, templates, validator, engine);

ThymeleafNotificationRendererCanonicalNotificationRenderer에 위임할 뿐이므로, template-engine=thymeleaf 배포에서 가드 없는 경로가 유일한 경로다.

실패 시나리오 — 템플릿이 ${amount.formatted('%.2f')}처럼 평가에 실패할 수 있는 식을 쓴다(변수는 존재하므로 requireEveryReferencedVariable은 통과한다 — 그 검사는 식의 루트 식별자만 본다, :111-112). Thymeleaf가 TemplateProcessingException을 던지고, 그 메시지는 실패한 식과 StringTemplateResolver가 템플릿 이름으로 쓰는 소스 문자열 자체를 포함한다. 아무도 잡지 않으므로 어댑터 밖으로 그대로 나가 상위 로거에 기록된다. 클래스가 막으려고 명시한 바로 그 노출이다 — 다만 막는 코드는 옆 오버로드에 있다.

PlaceholderTemplateEngine에는 이 문제가 없다. 식을 평가하지 않고, 자체 실패는 이미 리댁션된 NotificationFailureDescriptor로 던진다. 발견은 Thymeleaf 엔진에 한정된다.

권고:80:90-99와 같은 try/catch로 감싼다. 두 오버로드가 같은 process 호출을 서로 다르게 다루는 상태 자체가, default 메서드 사건(NotificationTemplateEngine javadoc :79-84)에서 이 코드베이스가 이미 배운 형태다 — "the engine that skipped it looked complete because the interface compiled."

17.3 P3/기록 — requireAllowedScheme이 trim한 값으로 검사하고 원본을 반환한다
// TemplateSlotPolicy.java:114-122
String normalized = value.trim().toLowerCase(Locale.ROOT);
boolean allowed = ALLOWED_URI_SCHEMES.stream().anyMatch(s -> normalized.startsWith(s + ":"));
if (!allowed) { throw refuse(); }
return value;      // ← trim 전 원본

" https://example.com"은 통과하고 선행 공백을 그대로 달고 나간다. 소비처는 URI.create(...)(CanonicalNotificationRenderer:103,110,117)이며 선행 공백은 URI.create에서 IllegalArgumentException이 된다 — 렌더 실패가 아니라 어댑터 밖 예외로 나타난다. Locale.ROOT 소문자화는 정확하다(터키어 I 문제 회피). 스킴 검사 자체는 우회되지 않으므로 보안 결함이 아니고, 실패 지점이 옮겨지는 것만 기록한다.

17.4 P3/기록 — render(String, Map)requireEveryReferencedVariable을 두 번 부른다

:88에서 한 번, :91contextFor가 다시 한 번(:157). 결과는 같고 비용만 두 배다. 위 §17.2대로 이 오버로드에 호출자가 없으므로 실행되지 않는다.

18. Sub-scope 04 완료 조건

  • denominator 32 / 32 FULL_READ
  • §8.1~§8.4 수행 — 도달성에서 P2 1건, 조건 형제 비교에서 P2 1건, 기록 2건
  • 소스 미변경

Sub-scope 05 — provider + core + platform/{provider,observation,reactor} (38 files, 29 main + 9 test)

내부 상태: COMPLETE — 38 / 38 FULL_READ · 근거 evidence/raw/185-notification-provider-core-probes.txt (main_count=29, test_count=9)

19. 무엇을 하는 코드인가

세 층이 한 sub-scope에 모인다.

core — 포크 프로젝트가 기여하는 단순 SPI(NotificationProvider)와 그 라우팅. RoutingNotifier는 이 저장소 전체에서 반복되는 fail-closed at boot 형태다: 채널 안 providerId 중복(:47-54)과 활성 provider 없는 라우트(:87-95) 둘 다 생성자에서 IllegalStateException으로 죽고, 메시지가 고칠 프로퍼티 키를 그대로 적어 준다. 런타임에 바인딩 없는 라우트로 notify하면 AdapterDisabledException — 조용한 no-op이 아니다. 팬아웃 루프에 try/catch가 없는 이유도 적혀 있다: 각 provider가 이미 FailOpenNotificationProvider로 감싸여 있어 sendthrows를 선언하지 않는다.

provider — 어댑터 내부 프로토콜. "순수 prepare → 인가된 단 한 번의 전송"으로 갈라 놓은 NotificationProviderAttemptClient와, 그것을 한 컴파일된 바인딩·렌더러·클라이언트에 묶는 NotificationProviderAttemptAdapter(363 LOC). 비밀 자료는 NotificationSecretMaterialHandle연산 범위 가변 사본을 받아 close()에서 Arrays.fill(...,0)으로 자신을 지우고(:59-70), toStringmaterial=<redacted>를 낸다(:72-79). 어댑터는 try-with-resources로 받는다(:227).

platform/provider — 모든 HTTP provider가 공유하는 변환 규칙. ProviderResults가 "커밋된 본문 + 응답 없음 = ambiguous"를 한 번만 쓴다(:26-36). EmailAttachments가 resolve·verify·close를 한 곳에 묶고, 부분 실패 시 이미 연 것을 전부 닫은 뒤 전파한다(:147-152).

platform/observation — 감사·지표·헬스. NotificationHealthSnapshot이 노출 대상을 "provider states, credential generations and queue age — nothing else"로 한정하고 그 이유를 적는다: "A health endpoint is one of the least protected surfaces an application exposes."

platform/reactor — Reactor를 핵심 계약 밖에 두는 선택적 파사드. boundedElastic에서만 블로킹하고 스스로 block()을 부르지 않으며, Mono 취소가 이미 커밋된 알림을 지우지 않는다 — "Undoing a durable acceptance because a subscriber went away would make the receipt meaningless."

20. Negative-space probes — sub-scope 05

20.1 (8.1) 도달성 — provider가 준 Retry-After는 실제로 쓰이는가

ProviderResults.retryAfter가 파싱한 값이 종단까지 도달하는지 추적했다. 도달한다:

$ grep -rn 'ProviderFailure::retryAfter' --include=*.java application-core/src/main
NotificationDispatchService.java:381:        result.failure().flatMap(ProviderFailure::retryAfter),

그리고 RetryBackoff.delay가 소비한다:

// RetryBackoff.java:43-45
Duration computed = Duration.ofMillis(Math.max(jittered, base.toMillis()));
Duration chosen = retryAfter.filter(hint -> hint.compareTo(computed) > 0).orElse(computed);
return chosen.compareTo(max) > 0 ? max : chosen;

힌트는 계산값보다 길 때만 채택되고, 그 뒤 설정된 max(기본 5분)로 상한이 걸린다. javadoc의 주장 — "A provider-supplied Retry-After always wins over the computed value, but never over the configured maximum: a provider asking for an hour must not silently extend a delivery deadline" — 이 코드와 정확히 일치한다. 악의적 provider가 Retry-After: 999999999로 배달을 수십 년 뒤로 미루는 경로는 없다. §17.1의 AccessContext와 대조되는, 회로가 닫힌 사례다.

20.2 (8.2) 조건 형제 비교 — 파서와 생성자의 음수 계약

두 지점이 같은 Duration을 서로 다른 계약으로 다룬다. 아래 §21.1.

20.3 (8.3) 중복 메커니즘 — 첨부 검증

EmailAttachments.openguard.resolve만 부르고 별도 verify를 부르지 않는다. javadoc은 "resolve, verify, close"를 약속한다. 드리프트가 아니라 위임이다 — 검증이 AttachmentIntegrityGuard.resolve 안에 있다:

// AttachmentIntegrityGuard.java:34-43
ResolvedAttachment attachment = resolver.resolve(reference, context);
if (attachment.size() != reference.expectedSize())                        { closeQuietly(...); throw integrityFailure(); }
if (!constantTimeEquals(attachment.digest(), reference.expectedDigest())) { closeQuietly(...); throw integrityFailure(); }

크기와 다이제스트를 제출 시점에 고정된 참조와 대조하고, 다이제스트 비교는 MessageDigest.isEqual로 상수 시간이다. 거부된 스트림은 예외 전에 닫힌다. 중복 아님, 드리프트 아님.

20.4 (8.4) 문서/카운트 드리프트 — 어떤 상태가 unhealthy인가

NotificationHealthReporter.snapshot()healthy = false로 넘어가는 조건은 넷이다:

조건 위치
감시 대상 프로파일이 레지스트리에 없음 (UNREGISTERED) :57-62
상태가 AUTHENTICATION_FAILED 또는 DISABLED :64-67
provider는 있는데 라우팅된 채널이 하나도 없음 :78-80
서빙 상태가 선언된 임계치를 넘음 :83-85

세 번째 조건에는 이 저장소 특유의 자기고발이 붙어 있다: "A platform with providers but no route accepts every request and delivers none. It was reported healthy because every runtime was healthy — which was true and beside the point."

여기서 눈에 띄는 것은 DRAINING이 목록에 없다는 점이다. 로테이션 중 드레인은 정상 운영이므로 그 자체로는 옳다. 그러나 §13의 P2와 겹치면 부작용이 하나 더 생긴다 — 아래 §21.2.

21. Sub-scope 05 findings

21.1 P3 — 음수 Retry-After 헤더가 throttle 결과 대신 IllegalArgumentException을 만든다

파서는 Long.parseLong이 받아들이는 값을 그대로 Duration으로 만든다:

// ProviderResults.java:90-99
public static Optional<Duration> retryAfter(Optional<String> headerValue) {
  return headerValue.flatMap(value -> {
    try { return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim()))); }
    catch (NumberFormatException notSeconds) { return Optional.empty(); }
  });
}

소비자는 음수를 거부한다:

// ProviderFailure.java:29-34   (canonical constructor)
retryAfter.ifPresent(delay -> {
  if (delay.isNegative()) { throw new IllegalArgumentException("retryAfter"); }
});

ProviderResults.fromStatus가 429를 만나면 파싱 결과를 그대로 new ProviderFailure(...)에 넣으므로(:50-55), Retry-After: -1을 보내는 provider는 throttle 분류 대신 IllegalArgumentException을 만든다. 이 예외는 NotificationFailureDescriptor를 달고 있지 않으므로 어댑터의 분류 체계 밖으로 나가고, NotificationSchedulerWorker의 catch-all이 reason=IllegalArgumentException으로 로그를 남긴 뒤 리스를 만료시킨다 — 회수가 재대사로 처리한다.

호출 경로 4개 모두 같다: SesFailureClassifier:37,49 · TwilioFailureClassifier:35,39 · WebPushFailureClassifier:39.

RFC 7231의 delta-seconds는 음이 아닌 정수이므로 준수하는 provider에서는 발생하지 않는다. 그래서 P3이다. 다만 이 값은 원격이 통제하는 입력이고, 파서가 NumberFormatException은 방어하면서 부호는 방어하지 않는 비대칭이 결함의 형태다. Optional.of(...).filter(d -> !d.isNegative()) 한 줄이면 닫힌다.

같은 파서가 HTTP-date 형식(Retry-After: Wed, 21 Oct 2026 07:28:00 GMT)을 받으면 Optional.empty()가 되어 계산된 백오프로 떨어진다. javadoc이 "expressed in seconds"라고 범위를 명시하므로 드리프트가 아니다.

21.2 P3/기록 — §13의 2단계 우회는 헬스 신호도 함께 끈다

§13에서 확인한 AUTHENTICATION_FAILED → markDraining() → resumeHealthy() 시퀀스는 상태와 원인 코드만 지우는 것이 아니다. NotificationHealthReporter가 unhealthy로 보는 상태 목록(:64-67)에 DRAINING이 없으므로, 3단계에서 헬스 엔드포인트가 즉시 정상으로 돌아온다 — 자격증명은 여전히 provider가 거부한 그것이다.

NotificationHealthReporter의 클래스 javadoc이 강조하는 바로 그 성질이 대상이다: "A provider whose credentials were rejected reports unhealthy even though the process is fine: that is exactly the condition an operator needs paged on, and it is invisible from process-level health." 관리자 평면의 두 호출이 그 페이징을 끄고, 첫 실패 시도가 다시 상태를 되돌릴 때까지 아무도 알지 못한다.

§13의 권고(관리자 경로는 조건부 드레인)를 적용하면 이 항목도 함께 닫힌다. 별도 결함으로 세지 않고 §13의 파급으로 기록한다.

22. Sub-scope 05 완료 조건

  • denominator 38 / 38 FULL_READ (main 29 + test 9, probe가 카운트 확인)
  • §8.1~§8.4 수행 — 도달성 확인 1건(회로 닫힘), 조건 형제 비교에서 P3 1건, 기록 1건
  • 소스 미변경

Sub-scope 06 — platform/provider/* 8종 구현 (76 files, 60 main + 16 test)

내부 상태: COMPLETE — 76 / 76 FULL_READ · 근거 evidence/raw/186-notification-provider-impl-probes.txt (main_count=60, test_count=16)

23. 무엇을 하는 코드인가

여덟 개 provider 구현(apns 4 · fcm 10 · http 6 · ses 11 · smtp 7 · twilio 9 · webhook 3 · webpush 10). 공통 골격이 일정하다 — properties(생성자 검증) · request mapper · failure classifier · adapter — 그리고 어느 것도 SDK를 끌어오지 않는다. AwsSignatureV4Signer의 javadoc이 이유를 적는다: SDK는 "its own HTTP client, retry policy and credential chain — three things this platform already owns and whose duplication would quietly move retry ownership out of the notification retry policy"를 함께 가져온다.

여기서도 자기고발 주석이 결함 이력을 남긴다:

  • SesRequestMapper 클래스 javadoc — 첨부를 선언한 이메일이 Simple 콘텐츠로 전송되고 있었다. "the document was silently absent from the mail SES sent and the attempt was still recorded as delivered, which is a recipient told to read something that is not there." 지금은 첨부가 있으면 Raw(MIME)로 간다. 그리고 그 MIME은 SMTP provider가 쓰는 같은 팩토리로 만든다 — "two builders would be two places for those answers to drift apart."
  • SmtpMimeMessageFactory:225-229 — JavaMail이 첨부를 두 번 읽는다(전송 인코딩 결정용, 파트 기록용). 리졸버의 일회성 스트림은 두 번째 읽기에서 빈 값을 돌려줬다. "The part that went out announced a filename and carried no bytes, so the mail arrived with an empty attachment and the attempt was still recorded as accepted." 지금은 ByteArrayInputStream 공급자로 넘긴다.
  • ApnsRequestMapper — "The capability declared a 4096-byte ceiling and nothing compared anything to it." 지금은 직렬화된 바이트에 대해 검사한다. FcmBatchCoordinator에도 같은 수정이 있다.
  • JdkNotificationHttpGateway.bodyWasLikelyCommitted:81-91 — 본문 커밋 여부를 getMessage()의 소문자 부분문자열("connection refused", "no route to host" 등)로 판정하고 있었다. "those strings come from the platform's C library and the JDK's own wording; they are localised on some platforms, they changed between JDK releases." 지금은 타입으로 판정하며, 기본값은 비관적인 쪽(커밋됨)이다. 원인 사슬 순회는 깊이 10으로 제한 — 순환 cause에 대한 방어까지 적혀 있다.
  • WebPushRequestMapper:107-112 — VAPID 서명 키를 "지금 활성인 키"가 아니라 "이 구독이 만들어질 때의 키"로 고른다. "VapidKeyRegistry already resolved the historical key per subscription — nothing called it."

암호 구현은 직접 작성했고 표준을 따른다. AwsSignatureV4Signer(SigV4 4단계 파생 키), SnsSignatureVerifier(SNS 정규 문자열 재구성 + 인증서 URL 제약), TwilioSignatureValidator(HMAC-SHA1 + MessageDigest.isEqual 상수시간 비교), Rfc8291Aes128GcmEncryptor(ECDH → HKDF ×2 → AES-128-GCM, 메시지마다 새 임시 키쌍), WebhookSignatureStrategy(타임스탬프를 서명 대상 안에 포함).

24. Negative-space probes — sub-scope 06

24.1 (8.1) 도달성 — SSRF 가드가 도달하는 호출처 전수

NotificationEndpoints.requireExternallyRoutable의 프로덕션 호출처는 뿐이다:

$ grep -rn 'requireExternallyRoutable' --include=*.java . | grep '/src/main/'
.../ses/SesProviderProperties.java:27:    NotificationEndpoints.requireExternallyRoutable(endpoint, "SES endpoint", true);
.../webhook/WebhookSubscription.java:49:  NotificationEndpoints.requireExternallyRoutable(target, "webhook target", trusted);

가드 자신의 javadoc이 지목하는 대상은 다른 둘이다:

"Web Push endpoints and webhook targets are supplied by clients, which makes this a server-side request forgery primitive."

Web Push는 목록에 없다. §25.1.

24.2 (8.2) 조건 형제 비교 — 두 개의 "안전한 엔드포인트" 판정

같은 판정을 하는 코드가 두 벌이다:

위치 검사 내용
강한 것 NotificationEndpoints.requireExternallyRoutable 스킴 · userinfo · 호스트 해석 · 내부 대역 전수
약한 것 NotificationEndpoints.requireSecureOrLoopback 스킴 + 루프백 호스트 이름
약한 것의 사본 WebPushSubscriptionValue.isSecureOrLoopback (private) 스킴 + 루프백 호스트 이름

세 번째가 문제다. NotificationEndpoints를 호출하지 않고 같은 논리를 private으로 재구현했으므로, 약한 가드의 이름으로 grep해도 이 호출처는 드러나지 않는다. §25.1.

24.3 (8.3) 중복 메커니즘 — MIME 조립

SES의 Raw 경로와 SMTP 경로가 같은 SmtpMimeMessageFactory를 쓴다. 명시적으로 중복을 없앤 사례이고 그 근거가 적혀 있다. 중복 아님.

24.4 (8.4) 문서/구현 드리프트 — 응답 본문 상한

JdkNotificationHttpGateway가 선언한 성질과 구현이 어긋난다. §25.2.

25. Sub-scope 06 findings

25.1 P2 — 클라이언트가 제공하는 Web Push 엔드포인트가 SSRF 가드를 지나지 않는다 (모듈 내 최고 영향도)

WebPushSubscriptionValue는 엔드포인트를 스킴만 검사한다:

// WebPushSubscriptionValue.java:27-28
if (!isSecureOrLoopback(endpoint)) {
  throw new IllegalArgumentException("endpoint must be https outside the loopback interface");
}
// :50-57  — NotificationEndpoints 를 호출하지 않는 private 사본
private static boolean isSecureOrLoopback(java.net.URI endpoint) {
  String scheme = endpoint.getScheme() == null ? "" : endpoint.getScheme();
  ...
  String host = endpoint.getHost() == null ? "" : endpoint.getHost().toLowerCase(Locale.ROOT);

그리고 그 엔드포인트는 매퍼가 그대로 POST 대상으로 쓴다:

// WebPushRequestMapper.java:113-118
return new NotificationHttpRequest(
    "POST", subscription.endpoint(), JdkNotificationHttpGateway.headers(headers),
    encrypted.body(), properties.timeout());

이 정확한 결함은 같은 저장소가 이웃 파일에서 이미 고쳤고, 고치면서 무엇이 남았는지까지 적었다:

// WebhookSubscription.java:29-33
// requireExternallyRoutable, not requireSecureOrLoopback. The scheme check accepted any HTTPS
// URL, so `https://169.254.169.254/` — the cloud metadata service — and every RFC 1918 address
// passed. The stronger guard was written for exactly this call site and then called from
// nowhere: it existed, its own tests were green, and the two sites it was written for kept the
// weaker check.

"the two sites it was written for" — 가드 자신의 javadoc은 그 둘을 Web Push endpoints and webhook targets로 지목한다. 수정은 webhook target과 SES endpoint에 적용됐다. 즉 두 개의 클라이언트 제공 표면 중 하나만 연결되었고, 나머지 하나는 약한 검사의 private 사본을 계속 들고 있다.

호출처 테스트도 그 잘못된 쌍을 그대로 고정했다:

// EndpointGuardCallSiteTest.java:17-22
// It proved that for months while the function had no caller: both sites it was written for
// — a webhook target and an SES endpoint — kept calling requireSecureOrLoopback ...

이 테스트 클래스에 WebPush를 언급하는 줄은 0개다(probe §8.1). 클래스의 존재 이유가 "가드가 필요한 곳에서 실제로 도달되는가"인데, 그 "필요한 곳" 목록이 가드 javadoc의 목록과 다르다.

실패 시나리오 — 포크 애플리케이션이 이 플랫폼이 전제하는 구독 등록 경로를 붙인다(브라우저의 PushSubscription을 받아 WebPushSubscriptionValue로 저장). 클라이언트가 엔드포인트를 https://169.254.169.254/latest/meta-data/ 또는 https://10.0.0.5:8500/v1/kv/...로 보낸다. 스킴 검사를 통과하고, 암호화되어 저장되고, 해당 수신자에게 알림이 갈 때마다 배포 네트워크 내부에서 그 주소로 POST가 나간다. 응답 본문이 클라이언트에게 돌아가지는 않으므로 blind SSRF지만, URL과 일부 헤더(topiccontent.options().topic()에서 온다)를 공격자가 정한다. 실패 분류기가 상태 코드를 구분하므로 내부 호스트·포트 열거의 오라클도 된다.

현재 저장소에서의 정확한 상태 — main 소스에서 WebPushSubscriptionValue를 생성하는 곳은 AesGcmContactPointProtector.parseWebPush(저장소에서 읽어올 때) 한 곳뿐이고, 요청 데이터로부터 만드는 인바운드 경로는 이 템플릿에 동봉되어 있지 않다. 그래서 P1이 아니라 P2다. 다만 레코드의 표준 생성자가 이 값의 유일한 검증 지점이고, 그것이 이 모듈의 계약이다 — 포크가 등록 엔드포인트를 붙이는 순간(그것이 이 모듈의 존재 이유다) 검증은 이미 통과되어 있다.

권고WebPushSubscriptionValue의 private 사본을 지우고 NotificationEndpoints.requireExternallyRoutable(endpoint, "web push endpoint", false)를 호출한다. 루프백 허용은 false여야 한다 — 구독은 정의상 클라이언트가 이름 붙인 주소이고, WebhookSubscriptiontrusted=false에 대해 내린 결정과 같은 근거다. 그리고 EndpointGuardCallSiteTest에 Web Push 케이스를 추가한다. 그 클래스가 존재하는 이유가 정확히 이 종류의 누락이다.

25.2 P2 — "상한을 두고 읽는다"는 본문 핸들러가 전부 읽은 뒤에 자른다
// JdkNotificationHttpGateway.java:116-128
/**
 * A body handler that stops reading at the cap.
 * ...
 */
private static HttpResponse.BodyHandler<byte[]> boundedBody(int maxBytes) {
  return responseInfo ->
      HttpResponse.BodySubscribers.mapping(
          HttpResponse.BodySubscribers.ofByteArray(),
          body -> body.length <= maxBytes ? body : java.util.Arrays.copyOf(body, maxBytes));
}

BodySubscribers.mapping(upstream, finisher)의 finisher는 upstream이 완료된 뒤 그 결과에 적용된다. upstream은 ofByteArray()이고, 그것은 무제한으로 요청하여 본문 전체를 힙에 모은다. 잘라내기는 그 다음이다.

호출부 주석이 막겠다고 선언한 것과 정확히 반대다:

// :60-62
// Bounded, not ofByteArray(). A provider response is diagnostic — a status, some headers, an
// error document — and reading it without a cap makes the sender's heap a function of what
// the far end chooses to send. A chunked response with no end is a single-request outage.
HttpResponse<byte[]> response = client.send(builder.build(), boundedBody(MAX_RESPONSE_BYTES));

주석은 "not ofByteArray()"라고 쓰여 있고, 두 줄 아래 함수가 ofByteArray()를 upstream으로 쓴다.

실패 시나리오 — provider(또는 그 자리에 들어온 무엇이든)가 응답으로 큰 본문을 빠르게 보낸다. 요청 타임아웃(request.timeout())이 시간은 제한하므로 "끝나지 않는 chunked 응답"은 타임아웃에서 끊긴다 — 그러나 주석이 두 번째로 든 위험, 즉 "보내는 쪽의 힙이 저쪽이 보내기로 한 양의 함수가 되는" 상황은 그대로다. 10초 타임아웃 안에 2 GB를 받을 수 있는 링크에서는 2 GB가 전부 byte[]로 쌓인 뒤 64 KiB로 잘린다. 동시 발송이 많을수록 배수로 늘어난다. 이 leaf는 발송 워커가 virtual thread로 팬아웃하므로(NotificationSchedulerWorker:62) 동시 호출 수는 maxGlobalConcurrency만큼이다.

Rfc8291Aes128GcmEncryptor가 페이로드를 4 KiB로 제한하고 ApnsRequestMapper가 4,096바이트를 강제하는 등 보내는 쪽의 크기는 일관되게 통제된다. 통제되지 않는 것은 받는 쪽이며, 그것이 유일하게 원격이 정하는 크기다.

권고BodySubscribers.ofByteArray() 대신 상한에서 구독을 취소하는 subscriber를 쓴다(BodySubscribers.fromSubscriber(...) 위에 카운터를 두고 초과 시 subscription.cancel(), 또는 ofByteArrayConsumer로 누적하며 상한에서 중단). 주석이 이미 옳은 설계를 서술하고 있으므로 바꿀 것은 구현뿐이다.

25.3 P3 — SigV4가 서명한 host에 포트가 없어, 기본 포트가 아닌 엔드포인트에서 서명이 어긋난다
// SesRequestMapper.java
String host = properties.endpoint().getHost();          // 포트 없음
var signed = signer.sign("POST", PATH, "",
    Map.of("host", host, "content-type", "application/json"), ...);

SigV4의 정규 요청은 실제로 전송되는 Host 헤더 값을 서명해야 하고, 기본이 아닌 포트는 그 값에 포함된다. 요청 자체는 host 헤더를 싣지 않으며(JdkNotificationHttpGateway.RESTRICTED가 거부하고 JDK가 URI에서 채운다), JDK는 localhost:4566 같은 값을 보낸다. 서명은 localhost에 대해 이루어졌다.

포트 443(실제 SES)에서는 두 값이 같으므로 프로덕션은 영향이 없다. 영향 범위는 이 저장소가 명시적으로 지원하는 로컬/컨트랙트 프로파일 — EndpointGuardCallSiteTest:119http://localhost:4566을 유효한 SES 엔드포인트로 확인한다 — 이며, 서명을 실제로 검증하는 에뮬레이터를 그 자리에 두면 SignatureDoesNotMatch가 된다. host + (port == -1 || port == 443 ? "" : ":" + port)로 닫힌다.

25.4 P3 — SigV4 서명 키 파생이 비밀을 지울 수 없는 String으로 승격시킨다
// AwsSignatureV4Signer.java:117-119
byte[] key = ("AWS4" + new String(secretAccessKey, StandardCharsets.UTF_8))
    .getBytes(StandardCharsets.UTF_8);

secretAccessKeyNotificationSecretMaterialHandle이 제공하는 지울 수 있는 가변 사본이다. 그 핸들의 존재 이유가 "operation-scoped mutable secret copy that wipes itself on close"이고, close()Arrays.fill(bytes, (byte) 0)을 한다. 이 한 줄이 그 바이트를 불변 String으로 복사하며, 그 String은 GC가 가져갈 때까지 힙에 남고 어떤 close()도 지울 수 없다. 힙 덤프 한 장이면 회수된다.

기능적으로도 UTF-8 왕복이다 — AWS 비밀 키는 ASCII이므로 손상되지 않지만, 바이트를 문자로 해석할 이유 자체가 없다. concat("AWS4".getBytes(US_ASCII), secretAccessKey)가 같은 결과를 String 없이 만든다.

같은 패턴을 leaf 전체에서 찾았고(probe §8.3) 이 한 곳뿐이다. 나머지 비밀 사용처는 모두 byte[]를 그대로 SecretKeySpec에 넣는다.

25.5 P3/기록 — SNS SignatureVersion 1(SHA-1)을 발신자가 선택할 수 있고, v2를 요구할 설정이 없다
// SnsSignatureVerifier.java:56, 66
if (!"1".equals(version) && !"2".equals(version)) { return false; }
...
Signature.getInstance("2".equals(version) ? "SHA256withRSA" : "SHA1withRSA");

이전 결함("필드가 없으면 기본 1로 떨어져 조용히 SHA-1로 내려갔고, 그 필드는 공격자가 정한다")은 고쳐졌다 — 이제 값이 정확히 1 또는 2여야 한다. 남은 것은 여전히 1을 받아들인다는 점이고, 어느 것을 쓸지는 봉투가 정한다. SesProviderProperties에 v2를 강제할 옵션이 없다(probe §8.4: SignatureVersion 참조는 검증기 한 줄뿐).

위조 가능성은 아니다 — 어느 쪽이든 AWS의 개인 키가 필요하고, 여기서 요구되는 것은 SHA-1 충돌이 아니라 프리이미지다. AWS가 v2를 별도로 제공하는 이유가 소비자가 SHA-1 수용을 끊을 수 있게 하는 것이므로, 그 선택지가 배포에 없다는 사실만 기록한다.

25.6 P3/기록 — ApnsProviderProperties.allowedPushTypes가 표현할 수 있는 질문이 하나뿐이다

ApnsRequestMapper가 push type을 상수 "alert"로 고정한 뒤 허용목록과 대조한다. 따라서 이 집합에 대해 물을 수 있는 것은 "alert을 포함하는가"뿐이고, {"background","voip"}로 설정하면 모든 발송이 설정 오류로 실패하며 {"alert","background"}{"alert"}와 동작이 같다. apns-push-type을 콘텐츠에서 유도하기 전까지는 검증만 있고 선택이 없는 설정 항목이다.

25.7 P3/기록 — 공개 hkdf가 32바이트를 넘는 요청을 조용히 0으로 채운다

Rfc8291Aes128GcmEncryptor.hkdf는 expand를 한 블록만 수행한다(update(info); update((byte)1); doFinal()). RFC 8291의 모든 용도가 ≤32바이트라 현재 호출은 전부 안전하지만, public static이므로 재사용 가능한 표면이다. length > 32이면 Arrays.copyOf가 뒤를 0으로 채운 값을 오류 없이 돌려준다. if (length > 32) throw 한 줄이면 닫힌다.

26. Sub-scope 06 완료 조건

  • denominator 76 / 76 FULL_READ (main 60 + test 16, probe가 카운트 확인)
  • §8.1~§8.4 수행 — 도달성에서 P2 1건, 문서/구현 드리프트에서 P2 1건, P3 2건, 기록 3건
  • 소스 미변경

Sub-scope 07 — slack/webhook + email/google + testkit + 템플릿 리소스 (19 files, 6 main + 9 test + 4 resources)

내부 상태: COMPLETE — 19 / 19 FULL_READ · 근거 evidence/raw/187-notification-spi-testkit-probes.txt (main_count=6 test_count=9 resource_count=4)

27. 무엇을 하는 코드인가

두 갈래다.

단순 SPI 기여 2종SlackWebhookProviderGoogleEmailProvider. 각각 30~40 LOC이고 정책을 담지 않는다. fail-open과 PII-안전 로깅은 FailOpenNotificationProvider가 중앙에서 적용하고, SDK는 포크 프로젝트가 SlackClient / GoogleEmailClient 심(seam)으로 넣는다. 두 @Configuration 모두 @ConditionalOnProperty(havingValue="true", matchIfMissing=false)로 닫혀 있고, 심 빈이 @Bean 메서드의 필수 파라미터이므로 배선 누락은 컨텍스트 시작 실패가 된다 — provider 클래스에 Objects.requireNonNull이 없다는 점을 확인했으나, 지원되는 배선으로는 도달할 수 없다. 결함 아님.

testkit 9종 — 이 leaf가 스스로에게 거는 게이트. 세 개가 서로 다른 질문을 맡는다:

  • ProviderAdapterContract — "수용이란 무엇이고, 거절이란 무엇이며, 답이 오지 않으면 어떻게 되는가". 서브클래스가 어댑터와 fault harness만 공급한다.
  • CrossProviderContractSuiteTest — "모두가 같은 단어로 같은 것을 뜻하는가". 클래스 javadoc이 이유를 정확히 적는다: "Per-provider tests answer 'does this adapter map this provider's responses correctly'. This suite answers a different question... it is exactly what drifts when each adapter is only ever tested against its own expectations."
  • NotificationChaosSecurityTest — 고장 주입으로만 드러나는 두 가지. 커밋 후 응답 손실(모든 어댑터가 AMBIGUOUS를 보고해야 함 — "this is the single case that cannot be discovered from a provider's documentation, only from cutting the connection")과 텔레메트리 비밀 누출.

ProviderFaultHarness는 목이 아니라 실제 JDK HTTP 서버 소켓을 띄운다: "the behaviour under test is what happens when a connection dies after the request body has been written — and a mock cannot reproduce that without also mocking away the thing being verified."

PiiLeakScanner는 패턴이 아니라 테스트가 사용한 정확한 픽스처 값을 찾는다: "it catches a token that looks like ordinary text, which is precisely the value a pattern-based scanner misses."

NotificationPerformanceCertificationTest는 처리량 수치를 게이트로 삼지 않는다 — "A throughput figure from a laptop is worthless as a gate, and a gate that varies with the machine is one people learn to re-run." 대신 규모와 무관하게 성립해야 하는 성질(식별자 순서·유일성, 동시성 상한, rate limit이 흡수가 아니라 거절)만 확인한다.

리소스 4종 — 컨트랙트 이메일 템플릿 3개(html/subject/text)와 Slack 템플릿 1개. §sub-scope 02의 체크섬 고정 대상이다.

28. Negative-space probes — sub-scope 07

28.1 (8.1) 도달성 — 공유 계약을 실제로 상속하는 어댑터
$ grep -rn 'extends ProviderAdapterContract' adapter/outbound/notification/src/test
.../ses/SesNotificationProviderAdapterTest.java:50
.../twilio/TwilioSmsProviderAdapterTest.java:24
.../apns/ApnsNotificationProviderAdapterTest.java:31

8종 중 3종. 클래스 javadoc은 "Subclasses supply an adapter and a fault harness; the assertions are here so that a new provider cannot be added without answering the same three questions"라고 쓰지만, 상속을 강제하는 장치는 없다. 이 저장소는 같은 종류의 강제를 다른 곳에서는 만들어 두었다 — EndpointGuardCallSiteTest(가드가 호출처에서 실제로 도달하는가), verifyNotificationApiSurface(공개 타입 586개 스냅샷 고정). 여기에는 없다.

ContractAdapters가 크로스-프로바이더 스위트에 등록하는 것은 5종(ses · twilio · apns · webpush · webhook)이다. 빠진 둘은 fcm과 smtp이고, 그것은 harness의 구조적 한계로 설명된다 — 스위트는 HTTP 루프백 서버 위에서 돌고, SMTP는 JavaMail 릴레이로, FCM은 FcmGateway 심으로 나간다.

28.2 (8.2) 조건 형제 비교 — transport 실패를 ambiguous로 번역하는 어댑터
$ grep -rln 'ProviderResults.fromTransport' adapter/outbound/notification/src/main
.../platform/provider/apns/ApnsNotificationProviderAdapter.java
.../platform/provider/ses/SesNotificationProviderAdapter.java
.../platform/provider/smtp/SmtpNotificationProviderAdapter.java
.../platform/provider/twilio/TwilioSmsProviderAdapter.java
.../platform/provider/webhook/WebhookNotificationProviderAdapter.java
.../platform/provider/webpush/WebPushNotificationProviderAdapter.java

여섯. FCM이 없다. §29.1.

28.3 (8.3) 중복 메커니즘 — 두 개의 "모든 provider" 집합

ProviderAdapterContract 상속(3종)과 ContractAdapters 등록(5종)은 겹치되 같지 않고, 어느 쪽도 완전하지 않으며, 어느 쪽도 구조적으로 강제되지 않는다. 두 집합의 합집합에서 빠진 유일한 provider가 fcm이다 — 그리고 §29.1이 보이듯 그것이 계약을 만족하지 않는 provider다. 두 사실은 같은 사실이다.

28.4 (8.4) 테스트 레인 실행
$ ./gradlew :adapter:outbound:notification:test
> Task :adapter:outbound:notification:test
BUILD SUCCESSFUL in 5s
GRADLE_EXIT=0

전 레인 통과. 이 모듈의 발견 중 테스트가 검출한 것은 없다 — §9(순환 fallback)와 §13(2단계 우회)은 통과하는 테스트가 다른 이유로 통과하거나 합성을 확인하지 않아 생긴 것이고, §29.1은 테스트 대상 밖이다.

29. Sub-scope 07 findings

29.1 P2 — FCM만 "커밋 후 응답 손실 = ambiguous" 규칙 밖에 있고, 그 FCM이 두 계약 집합 어디에도 없다

ProviderResults의 존재 이유가 클래스 javadoc에 쓰여 있다:

"Every HTTP provider adapter routes its transport failures through here, so the rule that 'committed body plus no response equals ambiguous' is written once rather than re-derived per provider."

FCM 경로는 그것을 지나지 않는다. 세 계층 전부에 번역도 방어도 없다:

// FcmGateway.java — 실패 계약이 선언되지 않은 심
@FunctionalInterface
public interface FcmGateway {
  FcmBatchResult sendBatch(List<Map<String, Object>> messages);
}

// FcmBatchCoordinator.submit — try/catch 없음, fromTransport 없음
FcmBatchResult batch = gateway.sendBatch(messages);
if (batch.items().size() != submissions.size()) { throw new IllegalStateException(...); }

// FcmNotificationProviderAdapter — 그대로 통과
return coordinator.submit(submissions);

NotificationHttpTransportException도, requestBodyCommitted 비트도 이 경로에는 존재하지 않는다. 다른 여섯 어댑터가 모두 catch (NotificationHttpTransportException) → ProviderResults.fromTransport(...)ProviderSubmissionResult.ambiguous(...) + ProviderExecutionEvidence.responseLost()를 만드는 자리에서, FCM은 구현이 던지는 임의의 RuntimeException을 그대로 올려보낸다.

실패 시나리오 — FCM 멀티캐스트 요청의 본문이 기록된 뒤 연결이 끊긴다. 배치가 maxBatchSize만큼의 수신자를 담고 있으므로 한 번의 손실이 최대 배치 크기만큼의 배달에 동시에 영향을 준다. 결과는 ambiguous가 아니라 분류되지 않은 예외다. NotificationSchedulerWorker의 catch-all이 reason=<예외 타입>으로 로그를 남기고(:102-106) 리스를 만료시킨다. LeaseRecoveryService가 미완 시도를 보고, provablyNeverStarted가 false이므로 재대사로 넘긴다. FCM의 ProviderCapabilities는 상태 질의를 false로 선언하므로(FcmNotificationProviderAdapter:48-59) ReconciliationJobWorker가 "질의 능력 없는 provider는 루프 돌지 않는다"는 규칙대로 작업을 종료하고 시도를 운영자에게 남긴다.

최종 방향은 안전하다 — 중복 발송이 아니라 운영자 대기다. 결함은 도착 경로다: 모델링된 AMBIGUOUS 경로 대신 미분류 예외로 도착하므로 (a) 시도 행에 기록되는 증거가 responseLost()가 아니라 없고, (b) 지표와 로그가 "ambiguous submission"이 아니라 예외 타입 이름을 말하며, (c) ambiguousAttemptExists 기반 라우팅 판단(NotificationDispatchService:377-379)이 이 경우를 보지 못한다.

검증 공백이 같은 공백이다 — FCM은 ProviderAdapterContract를 상속하지 않고(§28.1), ContractAdapters에 등록되지 않았으며(§28.1), FCM 테스트 두 개(FcmBatchAdapterTest · FcmExpiryTest) 어디에도 AMBIGUOUS 문자열이 없다(§28.2 probe). 계약을 강제하는 두 장치가 모두 비껴간 provider가, 계약을 만족하지 않는 provider다. ProviderAdapterContract의 세 질문 중 세 번째 — "what happens when the answer never arrives" — 가 FCM에 대해서는 물어진 적이 없다.

권고FcmGateway에 실패 계약을 선언한다(NotificationHttpTransportException을 던지도록 문서화하거나, requestBodyCommitted를 담은 FCM 전용 예외를 만든다). FcmBatchCoordinator.submit이 그것을 잡아 배치의 모든 항목에 대해 ProviderResults.fromTransport(...)를 돌려주게 한다 — 배치이므로 결과는 항목 수만큼의 ambiguous다. 그리고 ContractAdapters에 FCM을 등록한다. HTTP 루프백 harness로는 FcmGateway 심을 직접 고장 내는 편이 맞다.

29.2 P3 — 공유 provider 계약이 8종 중 3종에서만 상속되고, 강제 장치가 없다

§28.1·§28.3. ProviderAdapterContract의 javadoc이 약속하는 성질("a new provider cannot be added without answering the same three questions")을 지키는 장치가 없다. 이 저장소는 같은 실패 양식에 대해 EndpointGuardCallSiteTestverifyNotificationApiSurface라는 구조적 강제를 이미 두 번 만들었으므로, 형태는 이미 있다 — 적용되지 않았을 뿐이다.

세 번째 질문(응답이 오지 않을 때)의 실질 커버리지는 상속보다 넓다: smtp와 webhook은 자체 테스트에서 AMBIGUOUS를 확인하고, webpush는 크로스-프로바이더 스위트가 확인한다. 확인되지 않는 유일한 provider가 fcm이고 그것이 §29.1이다.

30. Sub-scope 07 완료 조건

  • denominator 19 / 19 FULL_READ (main 6 + test 9 + resources 4, probe가 카운트 확인)
  • §8.1~§8.4 수행 — 도달성·조건 형제 비교에서 P2 1건, P3 1건
  • 테스트 레인 실행: BUILD SUCCESSFUL, GRADLE_EXIT=0
  • 소스 미변경

31. 모듈 종합 — adapter-outbound-notification

31.1 커버리지 원장 정산

# sub-scope main test res 상태
1 governance + 루트 + platform/autoconfigure 13 5 4 22 COMPLETE
2 catalog/** + template/** 19 4 23 COMPLETE
3 platform/dispatch/** 23 7 30 COMPLETE
4 platform/template/** + platform/security/** 21 11 32 COMPLETE
5 provider + core + platform/{provider,observation,reactor} 29 9 38 COMPLETE
6 platform/provider/* 8종 60 16 76 COMPLETE
7 slack/webhook + email/google + testkit + 리소스 6 9 4 19 COMPLETE
합계 171 61 8 240 7 / 7

FULL_READ 240 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0. 각 sub-scope의 분모는 해당 evidence 파일의 OWNED FILES 블록과 *_count 라인이 확인한다(182187).

31.2 발견 종합 — P2 7건 · P3 4건 · 기록 8건

심각도 § 발견 위치
P2 9 SINGLE 전용 가드가 먼저 던져 다중 타깃 검증 전체(순환 탐지 포함)가 도달 불가이고, 그것을 검증한다는 테스트가 hasMessageContaining("strategy")로 다른 가드에 걸려 통과 NotificationBindingCompiler:141,159-184
P2 13 resumeHealthyAUTHENTICATION_FAILED를 거부한다는 보장이, 관리자 평면에 노출된 DRAINING → HEALTHY 2단계로 우회됨. 원인 코드도 소실 ProviderRuntime.markDraining · RegistryProviderRuntimeControl:30-39
P2 17.1 "Every reveal is auditable"를 선언한 AccessContext의 세 필드를 읽는 코드가 저장소에 0개. 감사 싱크는 존재하고 다른 경로는 사용 중 AesGcmContactPointProtector:86-97
P2 17.2 Thymeleaf 예외 메시지 삭제 가드가 프로덕션이 타지 않는 오버로드에만 있음 ThymeleafStringTemplateEngine:80 vs :92
P2 25.1 클라이언트 제공 Web Push 엔드포인트가 SSRF 가드를 지나지 않음. 약한 검사의 private 사본을 사용 WebPushSubscriptionValue:27-28,50-57
P2 25.2 "상한을 두고 읽는다"는 본문 핸들러가 ofByteArray()로 전부 읽은 뒤 자름 JdkNotificationHttpGateway:123-128
P2 29.1 FCM만 "커밋 후 응답 손실 = ambiguous" 규칙 밖. 배치라 한 번의 손실이 배치 크기만큼 영향 FcmBatchCoordinator · FcmGateway
P3 21.1 음수 Retry-After가 throttle 결과 대신 IllegalArgumentException ProviderResults:90-99
P3 25.3 SigV4가 서명한 host에 포트 없음 — 기본 포트 아닌 엔드포인트에서 서명 불일치 SesRequestMapper
P3 25.4 SigV4 키 파생이 비밀을 지울 수 없는 String으로 승격 AwsSignatureV4Signer:117-119
P3 29.2 공유 provider 계약이 8종 중 3종에서만 상속, 강제 장치 없음 ProviderAdapterContract
기록 9 · 13 · 17.3 · 17.4 · 21.2 · 25.5 · 25.6 · 25.7 indexOf 순번 · javadoc 2 vs 코드 3 · trim 후 원본 반환 · 중복 검증 호출 · 헬스 신호 동반 소등 · SNS v1(SHA-1) 수용 · allowedPushTypes 무의미 · 공개 hkdf 32바이트 초과 시 0 패딩

31.3 이 모듈의 성격

240개 파일 중 P1이 없다. 지금까지 분석한 13개 모듈에서 P1이 나온 것은 httpclient(TLS 실패가 CONNECT로 오분류) 하나뿐이고, 이 모듈은 그보다 크면서 P1이 없다.

대신 이 모듈은 자기고발 밀도가 저장소 최고다. javadoc이 "이 코드는 전에 무엇을 잘못했고 그것이 무엇을 망가뜨렸는지"를 반복해서 적는다. 표본:

  • "the one metric named 'queue depth' was the one that could not show a queue growing"
  • "a Semaphore grows when you release more than you took... silently, in the direction of overloading the provider"
  • "the limiter let more through than configured at exactly the moment traffic rolls over"
  • "the mail arrived with an empty attachment and the attempt was still recorded as accepted"
  • "A default that discards its own argument is not a fallback; it is the rule not applying, and the engine that skipped it looked complete because the interface compiled"
  • "The stronger guard was written for exactly this call site and then called from nowhere: it existed, its own tests were green"
  • "Both existed as classes with no caller... so the absence turned a deliberate design into a leak"

이 마지막 두 개가 이 모듈의 핵심 패턴이자 이 저장소 전체의 핵심 패턴이다 — 장치는 만들어졌고 회로가 닫히지 않았다. 이 모듈은 그 패턴을 스스로 세 번 찾아내 고쳤고(배경 작업자 3종 배선, webhook SSRF 가드, VAPID 역사 키 조회), 그 과정을 문서화했다. 이번 분석의 P2 7건 중 5건이 같은 패턴의 남은 사례다:

§ 만들어진 장치 닫히지 않은 회로
17.1 AccessContext + NotificationAuditPort + LoggingNotificationAudit 복호화 경로가 감사 포트를 부르지 않음
17.2 Thymeleaf 메시지 삭제 try/catch 프로덕션이 타는 오버로드에 없음
25.1 requireExternallyRoutable Web Push가 private 약한 사본을 씀
25.2 boundedBody 핸들러 상한이 읽은 뒤에 적용됨
29.1 ProviderResults.fromTransport FCM 경로가 지나지 않음

나머지 2건(§9, §13)은 다른 패턴이다 — 테스트가 통과하지만 그 이름이 약속한 성질은 검증하지 않는다. §9는 단언 문자열("strategy")이 네 개의 서로 다른 예외에 공통으로 들어 있어 어느 가드가 걸렸는지 식별하지 못하고, §13은 세 전이를 각각 새 객체에서 확인하고 합성을 확인하지 않는다. 두 경우 모두 테스트의 @DisplayName.as() 설명문이 깨진 성질을 정확히 서술하고 있다.

31.4 다른 모듈과의 대조

build.gradledependencyPolicy { absent ... }(§sub-scope 01)는 messaging 모듈의 P2(§messaging 3·4 — 통과 불가능한 verifyJsonSchemaRuntimeGraph, 잘못된 lockfile 주장)에 대한 해법이 같은 저장소 안에 이미 있다는 것을 보여준다. 주석이 주장하고 그래프가 반박하는 상태를, 빌드가 확인할 수 있는 선언으로 바꾸는 방식이다.

반대로 §25.1은 이 모듈이 자기 자신의 해법을 절반만 적용했다는 사례다. EndpointGuardCallSiteTest는 "가드가 필요한 곳에서 도달되는가"를 묻는 정확한 장치인데, 그 "필요한 곳" 목록이 가드 javadoc이 지목한 목록과 다르다.

31.5 완료 게이트

  • denominator 240 / 240 FULL_READ, STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0
  • 7개 sub-scope 각각 §8.1~§8.4 negative-space probe 수행
  • evidence 182187 생성, 각 파일에 OWNED FILES + revision + 실행 probe 포함
  • 실행 검증: :adapter:outbound:notification:testBUILD SUCCESSFUL, GRADLE_EXIT=0
  • 거짓 양성 후보 검증 후 기각: WebPushSubscriptionValue 스킴 검사(→ 실제 결함으로 승격), SlackWebhookProvider/GoogleEmailProvider null 검사 부재(→ @Bean 필수 파라미터로 도달 불가), EmailAttachments의 verify 누락(→ AttachmentIntegrityGuard.resolve 내부에 존재), RetryBackoff의 무제한 Retry-After(→ max로 상한), ReactorContextBridge의 무조건 remove()(→ 풀 스레드 위생상 옳음), ProviderRuntimeRegistry.replacecompute 내 예외(→ CHM이 매핑을 보존)
  • 소스 미변경 (git status --short = 0줄)

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 80개 (main 67 · test 10 · 기타 3).

src/adapter/outbound/notification/build.gradle
src/config/architecture/modules.json                    (adapter-outbound-notification 항목)

main:
  src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompiler.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCatalogException.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalog.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationPlanAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityCard.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationRouteDescriptor.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/core/FailOpenNotificationProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/core/NotificationProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailClient.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformMode.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationProviderAssembly.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationSecretRequirements.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/ProviderType.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryService.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationBackgroundWorkers.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationSchedulerWorker.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntime.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ReconciliationJobWorker.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RegistryProviderDispatchGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RuntimeDrainCoordinator.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthSnapshot.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/EmailAttachments.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ProviderResults.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsProviderProperties.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsRequestMapper.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/JdkNotificationHttpGateway.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationEndpoints.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpTransportException.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/AwsSignatureV4Signer.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsSignatureVerifier.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSignatureValidator.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSignatureStrategy.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/Rfc8291Aes128GcmEncryptor.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidKeyRegistry.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorContextBridge.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtector.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmNotificationPayloadProtection.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/HmacProviderRequestIdHasher.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRenderer.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/TemplateSlotMode.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/TemplateSlotPolicy.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRenderer.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptAdapter.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptClient.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandle.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackClient.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackWebhookProvider.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/template/LocalEmailRenderer.java
  src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateCatalog.java

test:
  src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchAdapterTest.java
  src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmExpiryTest.java
  src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/EndpointGuardCallSiteTest.java
  src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/ContractAdapters.java
  src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/CrossProviderContractSuiteTest.java
  src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/NotificationChaosSecurityTest.java
  src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/NotificationPerformanceCertificationTest.java
  src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/PiiLeakScanner.java
  src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/ProviderAdapterContract.java
  src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/ProviderFaultHarness.java

기타:
  docs/notification/api-surface-snapshot.txt
  src/build.gradle
  src/gradle/notification-api-surface.gradle

해석되지 않은 인용 (9종) — 외부 타입·문서상 약칭 등:
  evidence/raw/183-notification-catalog-dispatch-probes.txt
  evidence/raw/181-notification-module-inventory.txt
  evidence/raw/182-notification-activation-probes.txt
  ProviderRuntimeState.java
  evidence/raw/184-notification-template-security-probes.txt
  AccessContext.java
  evidence/raw/185-notification-provider-core-probes.txt
  evidence/raw/186-notification-provider-impl-probes.txt
  evidence/raw/187-notification-spi-testkit-probes.txt


A14. adapter-inbound-web

분석 중에는 14-adapter-inbound-web.md 파일이었다. 1,937줄.

adapter-inbound-web — 코드베이스 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-inbound-web
  • canonical state analysisFile: §A14 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/inbound/web · Gradle :adapter:inbound:web
  • registry allowed_dependencies: ["domain-core", "application-core", "shared-contract"]
  • registry runtime_memberships: ["app-bootstrap", "sample-portfolio"]
  • coverage ledger: FULL_READ 638 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


분석 대상 src/adapter/inbound/web · revision a24ece9cf797f7ea647e33bf846b115208ed1ba5 분모 638 tracked files (main 400 · test 150 · 대체 소스셋 84 · governance 4) LOC main Java 27,473 · test Java 18,319 근거 evidence/raw/188-inbound-web-module-inventory.txt 이하

0. 이 모듈의 크기와 형태

지금까지 분석한 13개 모듈 중 가장 크다. 두 번째로 큰 notification(240 files)의 2.7배이고, main Java LOC만 27,473이다.

소스셋이 여섯 개라는 점이 이 모듈의 첫 번째 특징이다:

source set files 무엇인가
main 400 어댑터 본체 (Java 397 + resources 3)
test 150 단위·슬라이스 테스트
testkit 54 다른 소스셋들이 공유하는 계약 하네스
webfluxContractTest 16 Reactor Netty 위에서 같은 계약을 재실행
jettyCompatTest 9 Jetty 위에서 같은 계약을 재실행
nginxProxyTest 5 실제 Nginx 뒤에서 프록시 헤더 계약

testkit이 별도 소스셋이고 뒤의 세 소스셋이 그것을 재사용한다는 구조는, "같은 계약을 서로 다른 런타임에서 돌린다"는 의도를 빌드 수준에 박아 둔 것이다. notification의 ProviderAdapterContract가 상속으로만 했던 일(그래서 8종 중 3종에만 적용됐던 일)을 여기서는 소스셋 분리로 한다.

main 패키지가 74개다. 이 분석은 그것을 12개 bounded sub-scope로 나눈다.

1. 커버리지 원장

# sub-scope main test 기타 상태
1 governance + config·settings·core·contract·moduleboundary·*/autoconfigure 30 17 4 51 COMPLETE
2 error + validation + envelope 23 10 33 COMPLETE
3 auth + authz + security 27 17 44 COMPLETE
4 ratelimit + admission + budget + */throttle 41 9 50 COMPLETE
5 idempotency + operation + operationasync + evidence 40 10 50 COMPLETE
6 pagination + cursor + conditional + cache + versioning 42 12 54 COMPLETE
7 http + json + advanced/codec + openapi 34 11 45 COMPLETE
8 observability + proxy + filter + mvc/*·webflux/* 잔여 38 15 53 COMPLETE
9 advanced/** (stream · patch · functional · virtualthread · blockingbridge · release) 52 13 65 COMPLETE
10 fileserver/** 51 22 73 COMPLETE
11 notification/platform/** + admin/** 22 4 26 COMPLETE
12 testkit + webfluxContractTest + jettyCompatTest + nginxProxyTest 0 10 84 94 COMPLETE
TOTAL 400 150 88 638 12 / 12

분할은 evidence/raw/188-...의 패키지 트리에서 기계적으로 계산했고, 각 파일이 정확히 한 sub-scope에 속한다(중복 0, 미할당 0).


Sub-scope 01 — governance + config·settings·core·contract·moduleboundary·*/autoconfigure (51 files)

내부 상태: COMPLETE — 51 / 51 FULL_READ · 근거 evidence/raw/189-inbound-web-governance-probes.txt (file_count=51)

2. 무엇을 하는 코드인가

build.gradle(258줄)이 이 모듈에서 가장 밀도 높은 문서다. 여섯 소스셋과 다섯 커스텀 레인을 선언하면서, 각 결정마다 "그렇게 하지 않으면 레인이 무엇을 인증하게 되는가"를 적는다:

  • jettyCompatTest가 자기 소스셋인 이유 — "Two servers in one source set means Spring Boot picks one and the 'Jetty' lane silently runs on Tomcat — a compatibility matrix that certifies the same container twice."
  • webfluxContractTestinherits()(아무것도 상속하지 않음)인 이유 — "the default is to extend testImplementation, which extends the leaf's own implementation and therefore carries spring-boot-starter-web — and with Tomcat on the classpath Boot deduces a servlet application, starts a servlet container, and the reactive gate certifies the servlet stack while reporting itself green."
  • nginxProxyTesttest에 들어가지 않는 이유 — "folding it into test would make every developer's check depend on a container runtime, and the usual outcome of that is an @Disabled that nobody notices has been there for months."
  • CBOR·XML이 compileOnly인 이유 — implementation이었을 때 "Spring Boot's Jackson auto-configuration registers an xmlMapper and a cborMapper the moment each backend is on the runtime classpath... So every deployment got three ObjectMapper beans... and, worse, silently began parsing application/xml request bodies. An Advanced capability that is off by default had turned XML deserialization on for everybody, which is the opposite of what the flag promises and an XXE surface nobody chose."
  • testweb-parity 태그를 제외하는 이유 — 세 레인의 기록을 비교하는 게이트인데 test 단독으로는 하나만 존재하므로 "a gate that fails because the others have not run yet is a gate people learn to ignore."

모듈 경계. 이 leaf는 설계상 23개 Gradle 모듈이어야 하는 것을 하나의 등록 leaf 안 74개 패키지로 담는다. 그 대체가 정직하려면 경계가 기계로 확인되어야 하고, WebStableModule(539줄 enum)이 각 모듈의 id · 패키지 · 순도 등급 · 허용 의존 집합을 선언한다. WebModuleBoundaryTest가 실제 소스 트리를 스캔해 양방향으로 대조한다.

core 9종은 프레임워크 자유(CORE 순도)이고 각 타입이 자기 불변식을 생성자로 강제한다. ActorContext가 대표적이다 — 인증된 액터를 만드는 유일한 경로가 subject를 요구하는 authenticated이고, 다른 생성 경로 anonymous()는 subject를 담을 수 없다. 그래서 "A header value therefore has no path into this type that does not go through authentication first"가 주석이 아니라 타입 사실이다. !authenticated && !subject.isBlank() 조합을 거부하는 이유도 적혀 있다 — "that pairing is how an unverified identifier reaches an audit record looking verified."

ExternalRequestContext는 프록시 뒤에서 URL을 만들기 위해 scheme·host·port·prefix 보관하고 나머지는 버린다 — "anything retained here would become a way for a caller to choose where a Location header points." prefix는 .. 순회까지 거부한다.

WebRequestContext의 deadline이 duration이 아니라 절대 시각인 이유: "A budget expressed as 'three seconds' restarts at every hop that reads it, so a request with a three second budget can spend nine; an instant cannot be accidentally renewed."

3. Negative-space probes — sub-scope 01

3.1 (8.1) 도달성 — 다섯 커스텀 레인이 실제로 실행되는가

build.gradle은 "A release compatibility gate that is not wired to a task is a document"라고 적고 다섯 개 Test 태스크를 등록한다. 그러나 등록은 실행이 아니다. Gradle 쪽 참조를 전수했다:

$ grep -rn 'webCrossStackParityTest|webFluxContractTest|webJettyCompatTest|webNginxProxyTest|webAdvancedTest' \
    --include=*.gradle --include=*.groovy . | grep -v 'adapter/inbound/web/build.gradle' | wc -l
0

check가 의존하는 것은 webSecurityBoundaryTest 하나뿐이다. 나머지 넷은 Gradle 그래프 어디에서도 참조되지 않는다.

CI가 닫는다. 다섯 워크플로가 다섯 레인을 전부 이름으로 호출한다:

web-pr.yml:77                :adapter:inbound:web:webCrossStackParityTest
web-pr.yml:112               :adapter:inbound:web:webNginxProxyTest
web-nightly.yml:45,46        webJettyCompatTest · webFluxContractTest
web-release.yml:45,46        webCrossStackParityTest · webNginxProxyTest
web-advanced-nightly.yml:46  webAdvancedTest
web-advanced-release.yml:45,46,53  webJettyCompatTest · webFluxContractTest · webAdvancedTest

webCrossStackParityTestdependsOn 'test', 'webJettyCompatTest', 'webFluxContractTest'이므로 PR 게이트 하나가 네 레인을 끌고 온다. 회로 닫힘. cache-redis의 RedisCommandMetadataDiff(main 참조 0 · lane 참조 0 · Gradle 태스크 없음)와 정확히 대조되는 상태다.

기록할 것: 결합이 Gradle이 아니라 YAML에 있다. ./gradlew :adapter:inbound:web:check를 로컬에서 도는 개발자는 test + webSecurityBoundaryTest만 얻는다. Docker를 요구하는 webNginxProxyTest에는 그 결정의 근거가 적혀 있지만, Docker가 필요 없는 webJettyCompatTest·webFluxContractTest·webCrossStackParityTest에는 없다. §4.1.

3.2 (8.2) 조건 형제 비교 — 두 자동설정의 게이트
MVC:     @ConditionalOnWebApplication(SERVLET)  + @ConditionalOnProperty(backend.web.mvc.enabled,     matchIfMissing = true)
WebFlux: @ConditionalOnWebApplication(REACTIVE) + @ConditionalOnProperty(backend.web.webflux.enabled, matchIfMissing = true)

둘 다 matchIfMissing = true기본 켜짐이다. notification·messaging·cache-redis가 전부 matchIfMissing = false(옵트인)인 것과 반대인데, 이유가 다르다: 저쪽은 선택적 능력이고 이쪽은 웹 애플리케이션의 본체다. 상호배타성은 프로퍼티가 아니라 @ConditionalOnWebApplication의 타입 수준에서 온다 — "a reactive application cannot accidentally activate the servlet filters even if both artifacts are on the classpath."

두 자동설정이 등록하는 빈은 MVC 12개, WebFlux 11개다. AutoConfiguration.imports에는 이 둘만 있다.

3.3 (8.3) 배선 — main 397개 파일 중 무엇이 실제로 컨텍스트에 들어가는가

app-bootstrap이 이 leaf에서 import하는 서로 다른 타입은 19개이고, 그중 18개가 fileserver.*, 나머지 하나가 auth.RestrictedPathRule이다. 즉 조립의 대부분은 명시적 배선이 아니라 컴포넌트 스캔이다:

@ComponentScan(basePackages = { ..., "dev.caskeleton.adapter", ... })   // CaSkeletonApplication
@ConfigurationPropertiesScan(basePackages = { ..., "dev.caskeleton.adapter.inbound.web", ... })

스캔에 직접 걸리는 것은 @RestController 18 · @RestControllerAdvice 6 · @Component 7 · @Configuration 23 = 54개 파일이고, @AutoConfiguration 2개가 별도로 들어온다. 나머지 ~340개는 그 빈들이 전이적으로 쓰는 라이브러리 타입이다. 이 sub-scope에서는 그 형태만 확인하고, 실제 미도달 여부는 각 sub-scope에서 판정한다.

CaSkeletonApplication의 스캔 설계에도 이 저장소의 자기고발이 있다: "The asymmetry that existed before — beans gated, settings not — is why a notification settings object bound itself in a deployment whose notification master was off. A capability whose beans are gated but whose settings still bind is gated only where somebody remembered to gate it."

3.4 (8.4) 문서/구현 드리프트 — 모듈 경계 선언과 실제 트리

WebModuleBoundaryTest가 다섯 개의 긍정 규칙과 네 개의 부정 픽스처를 갖는다:

규칙 부정 픽스처
모든 프로덕션 패키지가 선언된 모듈 정체성을 가진다 ROOT.undeclared 패키지를 만들어 거부되는지 확인
선언된 모든 모듈이 트리에 존재한다
모든 교차 모듈 import가 선언된 edge다 conditional → ratelimit 위반을 만들어 확인
CORE 모듈은 프레임워크 자유다 cursor@Component를 import하게 만들어 확인
스캔이 아무것도 못 찾으면 통과가 아니라 실패다 빈 디렉터리로 IllegalStateException 확인

부정 픽스처의 존재 이유가 명시돼 있다: "A boundary test that has never been shown to fail is indistinguishable from one that scans the wrong directory." 그리고 프로덕션 스캔에 fileCount() > 100 하한과 packages()에 특정 패키지 두 개가 있어야 한다는 확인이 함께 붙는다.

프레임워크 탐지 정규식에는 Jackson 2와 3이 둘 다 들어 있고 그 근거가 적혀 있다: "this repository runs on Spring 7, whose message converters take Jackson 3 — so a CORE module could have imported a mapper without this detector noticing, which is a hole in exactly the check that is supposed to have none."

이것은 이 저장소에서 확인한 경계 강제 중 가장 강하다. notification의 EndpointGuardCallSiteTest(호출처 목록이 가드 javadoc과 달랐던)와 달리, 여기서는 목록 자체가 스캔으로 생성된다.

3.5 (8.4b) CORS 검증

CorsSettingsenabled && allowedOrigins.isEmpty()allowCredentials && allowedOrigins.contains("*") 둘을 생성자에서 거부한다. 소비처를 확인했다 — SecurityConfig:151cfg.setAllowedOrigins(...)이고 setAllowedOriginPatterns가 아니다. 패턴 API였다면 "https://*" 같은 항목이 contains("*")를 빠져나가면서 Spring에서는 허용되어 자격증명 포함 임의 origin 반사가 됐을 것이다. 정확한 API 짝이다. 결함 아님.

4. Sub-scope 01 findings

4.1 P3/기록 — 네 레인의 결합이 Gradle이 아니라 다섯 개 워크플로 YAML에 있다

check에서 도달 가능한 것은 test + webSecurityBoundaryTest뿐이고, webCrossStackParityTest·webFluxContractTest·webJettyCompatTest·webNginxProxyTest·webAdvancedTest.github/workflows 다섯 파일이 이름으로 호출할 때만 돈다(§3.1). 태스크 이름이 바뀌면 Gradle 구성 시점이 아니라 CI 실행 시점에 깨지고, 워크플로 다섯 곳을 모두 고쳐야 한다.

Docker를 요구하는 webNginxProxyTestcheck에서 빠진 근거가 build.gradle에 명시돼 있다. 나머지 셋에는 없다 — 특히 webCrossStackParityTestdependsOn으로 세 레인을 이미 묶고 있어 Gradle 수준의 집계 지점이 이미 존재한다. 결함이 아니라 결합 위치의 기록이다.

4.2 P3/기록 — WebRequestId·WebTraceId가 문법을 갖지 않고, 그 불변식이 두 필터에 복제되어 있다

WebTraceId의 javadoc은 이렇게 주장한다:

"Held as a value rather than a raw header string so the one place that decides whether an inbound traceparent may be believed is a constructor rather than every call site that reads a header."

그러나 생성자는 null/blank와 길이 128만 본다. 문자 문법이 없다. 같은 core 패키지의 형제들은 전부 anchored 문법을 갖는다 — WebOperationName[a-z][a-z0-9.-]{2,127}, TenantContext[A-Za-z0-9][A-Za-z0-9_.-]{0,63}.

실제 결정은 두 필터에 있고, 둘 다 올바르다:

// mvc/filter/WebMvcRequestIdFilter.java:48       webflux/context/WebFluxRequestContextFilter.java:45
private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z0-9._-]{1,128}");

신뢰되지 않은 입력에서 이 두 타입을 만드는 지점은 이 둘뿐이고(생성 지점 전수 확인), 양쪽 다 anchored matches()를 쓰며, trustInboundRequestId는 기본 false다. traceparent는 [0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}로만 채택된다. 현재 노출은 없다.

기록하는 것은 불변식의 위치다. 같은 정규식이 두 파일에 리터럴로 복제돼 있고(WebFlux 자동설정 javadoc이 "duplicating six @Bean methods is the cheaper of the two costs"로 복제 자체는 의도했다고 밝힌다), 타입은 그것을 강제하지 않는다. httpclient에서 확인한 P3 — validatedDnsPinning 검사가 블로킹 오버로드에만 있고 리액티브에는 없던 — 과 같은 형태의 위험이며, 이 모듈은 아직 양쪽이 일치한다.

5. Sub-scope 01 완료 조건

  • denominator 51 / 51 FULL_READ (probe가 file_count=51 확인)
  • §8.1~§8.4 수행 — 레인 도달성 확인(회로 닫힘), 두 자동설정 게이트 비교, 배선 형태 확인, 경계 강제 검증
  • 기록 2건, 결함 0건
  • 소스 미변경

Sub-scope 02 — error + validation + envelope (33 files, main 23 + test 10)

내부 상태: COMPLETE — 33 / 33 FULL_READ · 근거 evidence/raw/190-inbound-web-error-probes.txt (file_count=33)

6. 무엇을 하는 코드인가

에러 번역 — 실패가 HTTP 상태와 본문이 되는 곳. 그런데 이 sub-scope에는 그런 곳이 두 개 있다.

계약 A — Envelope. GlobalExceptionHandler(417줄, @RestControllerAdvice, extends ResponseEntityExceptionHandler)가 22개 @ExceptionHandler + 6개 프레임워크 오버라이드로 모든 실패를 Envelope<Void>({success, data|error, traceId})로 만든다. 코드 어휘는 OperationalError. 함께 EnvelopeBodyAdvice성공 응답도 전부 Envelope로 감싼다.

계약 B — RFC 9457 problem+json. WebProblemFactory · ProblemCatalog · WebProblem · WebProblemSanitizer · SafeProblemDetailExtensions · ProblemCode와 두 전송 핸들러(WebMvcProblemExceptionHandler 182줄 · WebFluxProblemExceptionHandler 173줄)가 application/problem+json을 만든다. 코드 어휘는 ProblemCode.

계약 B의 위생 규칙은 정교하다. WebProblemSanitizer허용목록이 아니라 제거목록인데 그 선택을 정직하게 설명한다 — 여섯 패턴(스택 프레임 · 패키지 한정 타입명 · URL · 자격증명 · SQL · 파일 경로)을 지운 뒤 제어문자와 중복 공백을 접고 카탈로그가 정한 길이로 자른다. 자격증명 패턴에는 자기고발이 붙어 있다:

"An earlier version anchored on the keyword alone, which removed the word Bearer and published the token after it — a redaction that reads as if it worked."

WebProblem은 8개 고정 컴포넌트 record라 확장 멤버가 구조적으로 불가능하고, SafeProblemDetailExtensions가 그 이유를 적는다 — "Somebody adds cause 'just for debugging' and the exception message ships to every caller."

계약 A의 위생 규칙도 있다. ClientSafeErrorMessages가 코드별 고정 문구만 반환하고 예외 메시지를 절대 통과시키지 않는다. README가 그 규칙을 명시한다 — "클라이언트 메시지는 allowlist다. 예외 메시지, validation interpolated message, rejected request value, raw request URL은 넣지 않는다."

두 계약 각각은 잘 만들어져 있다. 문제는 둘이 같은 애플리케이션에 함께 있다는 것이다.

7. Negative-space probes — sub-scope 02

7.1 (8.1) 도달성 — 두 advice 가 한 컨텍스트에 함께 등록되는가

둘 다 @RestControllerAdvice이지만 컴포지션 루트에서의 운명이 다르다. CaSkeletonApplication의 컴포넌트 스캔이 정규식으로 다섯 web 패키지를 제외하고, 그중 하나가 problem 핸들러의 패키지다:

// CaSkeletonApplication.AUTO_CONFIGURED_PACKAGES  (@ComponentScan excludeFilters, FilterType.REGEX)
  ...
  |dev\.caskeleton\.adapter\.inbound\.web\.mvc\.error\..*
  |dev\.caskeleton\.adapter\.inbound\.web\.mvc\.budget\..*
  |dev\.caskeleton\.adapter\.inbound\.web\.mvc\.operation\..*
  |dev\.caskeleton\.adapter\.inbound\.web\.webflux\.error\..*
  |dev\.caskeleton\.adapter\.inbound\.web\.webflux\.operation\..*
컴포넌트 스캔 자동설정 등록 출하 컨텍스트
GlobalExceptionHandler (...web.error) 잡힘 등록됨
WebMvcProblemExceptionHandler (...web.mvc.error) 제외됨 없음 등록되지 않음
WebFluxProblemExceptionHandler (...web.webflux.error) 제외됨 없음 등록되지 않음
WebMvcBudgetExceptionHandler (...web.mvc.budget) 제외됨 없음 등록되지 않음
OperationHttpController (...web.mvc.operation) 제외됨 없음 등록되지 않음
ReactiveOperationHttpController (...web.webflux.operation) 제외됨 없음 등록되지 않음

AutoConfiguration.imports에는 두 플랫폼 자동설정만 있고 그중 어느 것도 이 여섯을 @Import하거나 @Bean으로 만들지 않는다. app-bootstrap이 이 타입들을 참조하는 횟수도 0이다(evidence/raw/203-composition-root-scan-boundary.txt).

7.2 (8.2) 조건 형제 비교 — 겹치는 예외 타입

다섯 예외 타입을 두 핸들러가 모두 선언한다:

예외 GlobalExceptionHandler WebMvcProblemExceptionHandler
MethodArgumentNotValidException :285 (오버라이드) :77
HttpMessageNotReadableException :301 :99
HttpRequestMethodNotSupportedException :315 :121
HttpMediaTypeNotSupportedException :341 :110
NoResourceFoundException :404 :158

problem 핸들러가 @Order(HIGHEST_PRECEDENCE + 10)이고 GlobalExceptionHandler가 무순서(LOWEST_PRECEDENCE)이므로, 둘이 함께 등록된 컨텍스트에서는 problem 쪽이 이긴다. 그런 컨텍스트는 두 패키지를 모두 스캔하는 테스트 슬라이스와 픽스처 애플리케이션이고, 출하 컴포지션 루트는 그런 컨텍스트가 아니다(§7.1). §8.1.

7.3 (8.3) 문서가 선언하는 것

README.md의 절 제목이 ## error — 에러 → Envelope 변환이고, 그 아래 결정이 명시적이다:

README.md:168
- **D5: RFC 7807 `ProblemDetail` 표현은 거부**하고 자체 `Envelope` 형식을 쓴다.

그리고 계약 B는 문서에 존재하지 않는다:

$ grep -c 'problem+json\|RFC 9457\|ProblemCode' README.md CLAUDE.md
README.md:0
CLAUDE.md:0

23개 main 파일이 구현하는 계약이 463줄 README와 201줄 CLAUDE.md 어디에도 언급되지 않는다.

CLAUDE.md:92-93은 순서 대역까지 배정해 둔다:

"Domain @RestControllerAdvice in a consuming module must be annotated @Order(Ordered.HIGHEST_PRECEDENCE) (or otherwise ordered ahead of this ...)"

WebMvcProblemExceptionHandlerHIGHEST_PRECEDENCE + 10을 쓰므로, CLAUDE.md가 시킨 대로 HIGHEST_PRECEDENCE를 쓴 소비 모듈의 도메인 advice가 그것보다도 앞선다. 어떤 계약이 응답하는지는 결국 채택자가 문서를 따랐는지에 달린다.

7.4 (8.4) 테스트가 두 advice 를 함께 세우는가
$ grep -rln 'WebMvcProblemExceptionHandler' src/test src/testkit
src/testkit/java/dev/caskeleton/webtestkit/ContractFixtureApplication.java     # GlobalExceptionHandler 참조 0

leaf의 어떤 테스트도 두 advice 를 한 컨텍스트에 세우지 않는다. 각각 자기 슬라이스에서만 검증된다. §8.1의 실패 시나리오가 초록색 스위트 아래에서 성립하는 이유다.

7.5 (8.4b) 미도달 유틸
심볼 프로덕션 호출자 판정
WebProblemSanitizer.alreadySafe 0 (테스트 포함 0) 죽은 public 메서드. 게다가 본문의 input.trim().toLowerCase(ROOT).isEmpty() 삼항은 !input.isBlank() 뒤에서 항상 false라 조건 자체가 죽어 있다
SafeProblemDetailExtensions.allowed/requireAllowed 0 결함 아님 — WebProblem이 8개 고정 컴포넌트 record라 확장 멤버 경로가 애초에 없다. 타입이 이미 강제하는 불변식의 문서화
WebProblemFactory.requireStatusAgreement 3 (BudgetProblemMapper:73 · IdempotentResponseWriter:68 · WebFluxIdempotentInvoker:154) javadoc은 "Called on the way out, before serialization, by every transport adapter"라고 하지만 두 problem 핸들러는 부르지 않는다. 그쪽은 ResponseEntity.status(problem.status())로 상태를 본문에서 직접 가져오므로 불일치가 구조적으로 불가능 — 과장된 주석이고 결함은 아님

8. Sub-scope 02 findings

8.1 P1 — RFC 9457 계약 23개 파일이 출하 애플리케이션에 등록되지 않는다. 두 플랫폼 자동설정은 협력자 빈만 소유하고, 스캔에서 제외된 여섯 컴포넌트는 소유하지 않는다

CaSkeletonApplication이 컴포넌트 스캔에서 다섯 web 패키지를 정규식으로 제외하고(§7.1), 그 이유를 명시한다:

"The web platform's error, budget and operation packages are here for a third reason. Their advices and controllers need beans that only exist when the corresponding platform auto-configuration is active, and a component scan finds them regardless — so an all-off or partially configured deployment failed to start on an unsatisfied dependency rather than simply not installing the control. Ownership by auto-configuration is what ties a control's presence to its dependency's."

진단도 조치의 방향도 옳다. 자동설정도 존재하고 .imports에 등록돼 있다app-bootstrap/build.gradle:98implementation project(':adapter:inbound:web')로 이 leaf를 물고, 저장소의 유일한 AutoConfigurationImportFilter는 JPA 전용(JpaOffAutoConfigurationImportFilter)이므로 두 자동설정은 출하 컨텍스트에 실제로 import된다. 그런데 그 자동설정이 소유하는 것은 협력자이고, 스캔에서 제외된 여섯 컴포넌트가 아니다:

$ cat .../META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
dev.caskeleton.adapter.inbound.web.mvc.autoconfigure.WebMvcPlatformAutoConfiguration
dev.caskeleton.adapter.inbound.web.webflux.autoconfigure.WebFluxPlatformAutoConfiguration

$ grep -rn 'WebMvcProblemExceptionHandler|WebFluxProblemExceptionHandler|WebMvcBudgetExceptionHandler|OperationHttpController|ReactiveOperationHttpController' app-bootstrap/src/main   -> 0
$ grep -rn 'mvc.error|webflux.error|mvc.budget|mvc.operation|webflux.operation' --include=*.imports --include=*.factories   -> 0
$ grep -rln 'WebProblemFactory' app-bootstrap/src/main   -> 0

두 플랫폼 자동설정은 @Bean 13개(MVC)와 10개(WebFlux)를 등록하고 @Import를 하나도 갖지 않는다. 등록되는 것은 전부 협력자다:

MVC   : InMemoryWebOperationCatalog · WebBudgetCatalog · ProblemCatalog · WebProblemFactory ·
        WebValidationExceptionMapper · WebValidationIssueMapper · WebWireTypeManifest ·
        WebUriPolicy · WebMethodPolicy · webStrictObjectMapper ·
        WebMvcRequestIdFilter · WebMvcEvidenceFilter · webMvcRequestContextConfigurer
Flux  : WebFluxRequestContextFilter · InMemoryWebOperationCatalog · WebBudgetCatalog ·
        ProblemCatalog · WebProblemFactory · WebValidationExceptionMapper ·
        WebUriPolicy · WebMethodPolicy · webStrictObjectMapper (+1)

스캔에서 제외된 다섯 패키지의 컴포넌트는 여섯 개이고, 전부 스테레오타입 애노테이션을 갖는데 main 참조가 0이다:

컴포넌트 패키지 애노테이션 main 참조
WebMvcProblemExceptionHandler mvc.error @RestControllerAdvice @Order 0
WebFluxProblemExceptionHandler webflux.error @RestControllerAdvice @Order 0
WebMvcBudgetExceptionHandler mvc.budget @RestControllerAdvice @Order 0
WebMvcBudgetFilter mvc.budget (없음) 0
OperationHttpController mvc.operation @RestController 0
ReactiveOperationHttpController webflux.operation @RestController 0

ProblemCatalogWebProblemFactory는 빈으로 존재하고, 그것을 사용하는 @RestControllerAdvice가 존재하지 않는다. 합성 루트의 javadoc이 말한 "Ownership by auto-configuration is what ties a control's presence to its dependency's"에서 dependency 쪽만 소유되고 control 쪽은 소유되지 않았다.

출하 컨텍스트의 실제 상태:

계약 구성 요소 등록
Envelope GlobalExceptionHandler(22 handler + 6 override) · EnvelopeBodyAdvice · ErrorResponseFactory · ClientSafeErrorMessages 등록됨 — 모든 실패에 응답
RFC 9457 problem+json WebProblemFactory · ProblemCatalog · WebProblem · WebProblemSanitizer · ProblemCode · SafeProblemDetailExtensions · ValidationIssue · BudgetProblemMapper · ThrottleProblemWriter · 두 전송 핸들러 (23 main files) 등록되지 않음

즉 이 leaf가 만든 두 에러 계약 중 하나만 출하되고, 위생 규칙이 정교한 쪽(WebProblemSanitizer의 여섯 제거 패턴, 닫힌 확장 집합, 카탈로그 기반 상태 일치 검사)이 등록되지 않는 쪽이다.

실패 시나리오 — 팀이 ProblemCode.VALIDATION_FAILED로 분기하는 클라이언트 SDK를 작성한다. WebProblemFactoryTest(122줄) · ProblemCatalogTest(99줄) · WebValidationExceptionMapperTest(125줄)가 전부 통과하고, /v3/api-docs에도 problem 스키마가 기여되지 않아(§27.2) 계약 불일치를 볼 방법이 없다. 배포된 API는 422 대신 Envelope의 400을 내고 code 필드는 ProblemCode가 아니라 OperationalError 어휘다. SDK의 모든 분기가 빗나간다.

두 번째 결과WebMvcBudgetExceptionHandler와 두 durable-operation 컨트롤러도 같은 정규식에 제외된다. §16.3과 §20.2에서 "게이트를 켜면 미충족 의존성으로 부팅 실패"라고 기록한 것은 정확히는 게이트를 켜도 빈이 생기지 않는다가 맞다 — 스캔이 잡지 않으므로 조건 평가에도 이르지 못한다. 결과(능력이 켜지지 않음)는 같다.

왜 지금까지 드러나지 않았는가 — problem 계약을 검증하는 테스트는 전부 그 패키지를 명시적으로 @Import하거나 스캔하는 슬라이스에서 돈다. 출하 루트의 스캔 경계를 재현하는 테스트가 없다. 반대로 NoResourceFoundErrorHandlingTestGlobalExceptionHandler@Import하는데, 그것이 우연히 출하 동작과 일치한다:

@WebMvcTest(controllers = Probe.class, excludeAutoConfiguration = SecurityAutoConfiguration.class)
@Import({ Probe.class, GlobalExceptionHandler.class, EnvelopeBodyAdvice.class })
class NoResourceFoundErrorHandlingTest {
  @Test void missingResourceUsesSafeRouteNotFoundEnvelope() throws Exception {
    mvc.perform(get("/assets/" + secret + ".js"))
        .andExpect(jsonPath("$.success").value(false))
        .andExpect(jsonPath("$.error.code").value("ROUTE_NOT_FOUND"))
        ...

이 테스트는 problem 핸들러를 컨텍스트에서 빼고 Envelope을 단언한다. 출하 루트에서도 problem 핸들러가 없으므로 결과적으로 프로덕션 동작을 맞게 서술한다 — 그러나 그 일치는 테스트가 스캔 경계를 재현해서가 아니라 두 컨텍스트가 우연히 같은 것을 빼서 생긴다. problem 핸들러가 언젠가 자동설정으로 등록되면 이 테스트는 여전히 통과하면서 틀린 답을 단언하게 된다.

권고 — 하나를 고른다. Envelope을 유지한다면 RFC 9457 23개 파일과 그 테스트를 제거하고 스캔 제외에서 mvc.error/webflux.error를 뺀다. problem+json으로 간다면 두 플랫폼 자동설정이 두 핸들러와 WebProblemFactory·ProblemCatalog를 등록하고, GlobalExceptionHandler에서 겹치는 다섯 @ExceptionHandler를 제거하며, README의 D5와 ## error — 에러 → Envelope 변환 절을 교체한다.

어느 쪽이든 출하 루트의 스캔·자동설정 경계를 재현하는 테스트가 필요하다(§48.1의 권고와 같은 장치). 그것이 없으면 "제외했는데 넘겨받지 않았다"가 다시 성립한다.

8.2 P3 — WebProblemSanitizer.alreadySafe가 죽은 메서드이고 그 안의 조건도 죽어 있다
public boolean alreadySafe(String input, int maxLength) {
  return input != null && !input.isBlank()
      && sanitize(input, maxLength)
          .equals(input.trim().toLowerCase(Locale.ROOT).isEmpty() ? REDACTED : input.trim());
}

호출자 0(프로덕션·테스트 모두). 그리고 !input.isBlank()가 이미 통과했으므로 input.trim()은 비어 있을 수 없고, toLowerCase는 공백 여부를 바꾸지 않는다 — 삼항의 REDACTED 가지는 도달 불가다. javadoc이 약속하는 용도("for asserting a message is already safe")를 수행하는 코드가 없다.

8.3 P3/기록 — requireStatusAgreement의 javadoc이 호출 범위를 과장한다

§7.5. "every transport adapter"가 부르는 것이 아니라 세 곳이 부르고, 두 problem 핸들러는 상태를 본문에서 직접 읽어 불일치가 구조적으로 불가능하다. WebBudgetOutcome:8은 이 검사가 실제로 한 번 무언가를 잡았다고 기록한다 — 동작하는 검사이고, 서술만 넓다.

9. Sub-scope 02 완료 조건

  • denominator 33 / 33 FULL_READ (probe가 file_count=33 확인)
  • §8.1~§8.4 수행 — 도달성에서 P1 1건, 미도달 유틸에서 P3 1건, 기록 1건
  • 소스 미변경

Sub-scope 03 — auth + authz + security (44 files, main 27 + test 17)

내부 상태: COMPLETE — 44 / 44 FULL_READ · 근거 evidence/raw/191-inbound-web-security-probes.txt (file_count=44)

10. 무엇을 하는 코드인가

여기에도 두 벌이 있다.

배선된 것 — auth (11 files). SecurityConfigSecurityFilterChain을 짓고, JWT 모드와 Redis-세션 모드를 배타적으로 분기한다. JWT 모드는 csrf.disable() + STATELESS + oauth2ResourceServer(jwt), 세션 모드는 CookieCsrfTokenRepository(secure · httpOnly=false · sameSite/path from settings) + IF_REQUIRED + migrateSession() + 커스텀 SecurityContextRepository. 신원은 JwtToAuthenticatedPrincipalConverter가 Keycloak식 realm_access.roles resource_access[*].roles 평면 roles를 합쳐 AuthenticatedPrincipal로 만든다. 이 경로가 실제로 요청을 인증한다.

세부는 꼼꼼하다 — requestCache(cache -> cache.disable())("This is an API boundary: never persist framework SavedRequest graphs in a session"), 진입점과 거부 처리기를 exceptionHandlingoauth2ResourceServer 양쪽에 설정("so every filter resolves to the same Envelope writer"), BearerTokenAuthenticationFilter의 실패 처리기까지 ObjectPostProcessor로 같은 진입점에 연결.

배선되지 않은 것 — security (11 files). 프레임워크 자유 신원 모델이다: AuthenticationView(보안 계층이 확정한 것의 읽기 전용 뷰) → WebActorContextResolver / WebTenantContextResolverWebSecurityContextBridgeSecurityIdentity(= ActorContext + TenantContext). 설계 논증이 정확하다:

WebActorContextResolver — "It takes AuthenticationView and nothing else — no request, no headers, no parameters. That is the whole enforcement: there is no argument here through which a caller-supplied value could reach an ActorContext."

WebTenantContextResolver.rejectTenantInputx-tenant-id · tenant-id · tenantid · tenant 중 하나라도 요청에 있으면 UntrustedTenantInputException. "ignoring leaves a cross-tenant attempt invisible, and the same client keeps trying."

authz (5 files)는 @RequiresPermissionAuthorizationPort에 위임한다. AuthorizationPortSupplier로 늦게 푸는 이유까지 적혀 있다 — "an advisor is built while BeanPostProcessors are still registering, and resolving the port there instantiates it — and its whole role/permission chain — too early to be post-processed." 미인증·미인식 principal은 전부 fail-closed.

11. Negative-space probes — sub-scope 03

11.1 (8.1) 도달성 — 신원 모델의 프로덕션 참조 수
WebSecurityContextBridge  : main_refs=0  test_refs=1
WebActorContextResolver   : main_refs=1  test_refs=0     ← 참조자는 WebSecurityContextBridge 하나
WebTenantContextResolver  : main_refs=1  test_refs=1     ← 같음
AuthenticationView        : main_refs=3  test_refs=1     ← 전부 위 세 파일
SecurityIdentity          : main_refs=1  test_refs=1
rejectTenantInput         : main_refs=2  test_refs=1     ← 선언 + 브리지 오버로드. 세 번째 호출자 없음
WebCorsPolicyValidator    : main_refs=0  test_refs=1
WebCsrfPolicyResolver     : main_refs=0  test_refs=1

AuthenticationView를 만드는 코드도 테스트뿐이다:

$ grep -rn 'AuthenticationView.(authenticated|anonymous)' --include=*.java src | grep -v security/AuthenticationView.java
test/.../security/WebSecurityContextBridgeTest.java:27, 42, 64, 75, 90

security 패키지 전체가 자기 안에서만 서로를 부르는 닫힌 섬이고, 바깥에서 들어오는 화살표가 없다. 교차 테넌트 가드 rejectTenantInput은 그 섬 안에만 있다.

11.2 (8.2) 조건 형제 비교 — 두 전송의 WebRequestContext 생산자
$ grep -rn 'new WebRequestContext(' --include=*.java src app-bootstrap/src
main/.../webflux/context/WebFluxRequestContextFilter.java:81      ← main 유일
testkit/.../fault/FaultFixtureController.java:143
testkit/.../fault/ReactiveFaultFixtureController.java:141
testkit/.../throttle/ThrottleFixtureSupport.java:63
testkit/.../operation/OperationFixtureSupport.java:123
test/... (6곳)
전송 생산자 소비자
WebFlux WebFluxRequestContextFilter:78-88 WebFluxRequestContextAccessor (Reactor context)
MVC 없음 WebMvcRequestContextArgumentResolverWebMvcRequestContextHolder.require()
$ grep -rn 'WebMvcRequestContextHolder.store' --include=*.java .     → 0
$ grep -rn 'WebMvcRequestContextHolder' --include=*.java src app-bootstrap/src | grep -v Holder.java
main/.../mvc/context/WebMvcRequestContextArgumentResolver.java:40:    return WebMvcRequestContextHolder.require(request);

§12.1.

11.3 (8.3) 필터 체인 순서 — publicPathsRestrictedPathRule
// SecurityConfig.java:83-94
.authorizeHttpRequests(auth -> {
    if (publicPaths.length > 0) { auth.requestMatchers(publicPaths).permitAll(); }   // ← 먼저
    for (RestrictedPathRule rule : restricted) {                                      // ← 나중
      auth.requestMatchers(rule.pathPattern()).hasAnyAuthority(rule.authorities());
    }
    auth.anyRequest().authenticated();
})

Spring Security는 첫 일치가 이긴다. 주석은 "Ordered before the authenticated catch-all: a management path must be refused at the transport"라고 하는데, 그 순서는 anyRequest()에 대해서만 성립하고 publicPaths에 대해서는 반대다. §12.3.

프로덕션 RestrictedPathRule 생산자는 하나다 — FileserverAdminPlaneConfiguration:36이 fileserver 관리 경로를 등록한다. publicPaths의 기본값은 ${SECURITY_PUBLIC_PATHS:${PRESENTATION_API_BASE_PATH:/v1}/healthcheck}로, 환경변수 하나로 전체가 대체된다.

11.4 (8.4) 익명 액터가 무엇을 만드는가
// OperationAccessPolicy.java:31-33
if (!context.actor().authenticated()) {
  return false;
}

fail-closed다. §12.1의 두 번째 결과.

12. Sub-scope 03 findings

12.1 P1 — 플랫폼 요청 컨텍스트가 서블릿에는 생산자가 없고, 리액티브에는 익명 액터로 고정되어 있다

서블릿 절반. WebMvcRequestContextHolder.store(...)의 호출자가 저장소 전체에서 0이다. 그런데 그것을 읽는 쪽은 자동설정이 등록한다:

// WebMvcPlatformAutoConfiguration.java:158-166
@Bean @ConditionalOnMissingBean(name = "webMvcRequestContextConfigurer")
public WebMvcConfigurer webMvcRequestContextConfigurer() {
  return new WebMvcConfigurer() {
    @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
      resolvers.add(new WebMvcRequestContextArgumentResolver());
    }
  };
}

그리고 그 리졸버가 하는 일은 WebMvcRequestContextHolder.require(request) 하나이며, 속성이 없으면 던진다:

// WebMvcRequestContextHolder.java  require(...)
throw new IllegalStateException(
    "no web request context on this request; a fabricated one here would make an"
        + " unauthenticated call look like an anonymous actor somebody chose");

속성을 놓는 코드가 없으므로 이 예외는 항상 던져진다. 서블릿 컨트롤러가 WebRequestContext 파라미터를 선언하면 그 엔드포인트는 언제나 500이다. 컨트롤러의 서명에서 HttpServletRequest를 몰아내려고 만든 기능이, 쓰면 반드시 실패하는 기능이다.

리액티브 절반. 생산자가 하나 있고, 열 개 컴포넌트 중 넷을 상수로 채운다:

// WebFluxRequestContextFilter.java:78-88
WebRequestContext context =
    new WebRequestContext(
        requestId,
        resolveTraceId(request),
        operationName(request),
        new ApiMajorVersion(1),          // 고정
        ActorContext.anonymous(),        // 고정
        TenantContext.none(),            // 고정
        Locale.ENGLISH,                  // 고정
        receivedAt,
        receivedAt.plus(requestBudget),
        externalRequest(request));

이 필터는 ReactiveSecurityContextHolder도, WebSecurityContextBridge도, AuthenticationView도 참조하지 않는다. 인증 결과가 요청 컨텍스트에 도달하는 경로가 없다. 인증된 호출자든 아니든 컨텍스트의 액터는 익명이고 테넌트는 없음이다.

같은 저장소가 이 정확한 위험을 서블릿 쪽에서는 이름 붙여 거부한다 — require의 메시지가 "a fabricated one here would make an unauthenticated call look like an anonymous actor somebody chose"다. 리액티브 생산자가 하는 일이 정확히 그 fabrication이다. 두 전송이 같은 위험에 정반대로 대응했고, 한쪽의 javadoc이 다른 쪽의 동작을 규탄한다.

실패 시나리오 (리액티브) — 인증된 사용자가 GET /operations/{id}로 자기 비동기 작업 결과를 조회한다. ReactiveOperationHttpControllerOperationAccessPolicy.mayAccess(operation, context)를 부르고, context.actor().authenticated()가 항상 false이므로 모든 조회가 거부된다. 정책의 주석은 "Absent and forbidden are answered identically on purpose"이므로 클라이언트는 404를 받는다. 비동기 작업 기능이 리액티브 전송에서 동작하지 않는다.

실패 시나리오 (서블릿) — 같은 엔드포인트가 WebRequestContext를 파라미터로 받으면 리졸버가 던져 500. 받지 않으면 컨텍스트를 얻을 경로가 없다.

방향은 fail-closed다. 데이터 유출이 아니라 기능 불능이다. 그래서 보안 사고가 아니라 지금 틀린 동작으로 P1이다.

왜 테스트가 잡지 못하는가WebRequestContext를 만드는 다른 열한 곳이 전부 테스트와 testkit이고, 전부 손으로 채운다. OperationAccessPolicyTest:54,110 · WebMvcIdempotentInvokerTest:237 · WebFluxIdempotentInvokerTest:212 · testkit의 FaultFixtureController:143 · ThrottleFixtureSupport:63 · OperationFixtureSupport:123. 계약 레인의 픽스처 컨트롤러조차 리졸버를 쓰지 않고 자기 컨텍스트를 만든다. 네 개 런타임을 가로지르는 크로스 스택 게이트가 있어도, 그 게이트가 도는 픽스처가 생산자를 우회한다.

권고 — (1) 서블릿에 store를 부르는 필터를 추가한다(WebMvcEvidenceFilter가 이미 요청당 한 번 도는 자리다). (2) 두 생산자가 보안 컨텍스트에서 액터·테넌트를 읽게 한다 — WebSecurityContextBridge가 그 목적으로 이미 존재한다(§12.2). (3) 픽스처가 손으로 컨텍스트를 만드는 대신 리졸버/필터를 지나게 한다. (3) 없이는 같은 상태가 다시 성립한다.

12.2 P2 — 프레임워크 자유 신원 모델과 교차 테넌트 가드가 프로덕션에서 한 번도 참조되지 않는다

§11.1. security 패키지 11개 파일이 서로만 참조하고 바깥에서 들어오는 화살표가 없다. AuthenticationView를 만드는 프로덕션 코드가 없으므로 WebSecurityContextBridge.resolve(...)가 호출될 수 있는 상태 자체가 없다.

가장 값이 큰 부분이 그 안에 있다:

// WebTenantContextResolver.java
private static final Set<String> TENANT_INPUT_NAMES =
    Set.of("x-tenant-id", "tenant-id", "tenantid", "tenant");

public void rejectTenantInput(Map<String, String> requestInput) { ... throw new UntrustedTenantInputException(); }

클라이언트가 테넌트를 제안하는 헤더를 거부하는 가드다. 도달 경로는 WebSecurityContextBridge.resolve(authentication, requestInput) 오버로드 하나뿐이고, 그 오버로드의 호출자는 테스트뿐이다. 그래서 지금 이 플랫폼은 X-Tenant-Id 헤더에 대해 거부도 무시도 하지 않는다 — 그 헤더를 보는 코드가 아예 없다.

노출은 아니다: 테넌트를 소비하는 유일한 지점(OperationAccessPolicy:37-40)이 context.tenant()를 읽고, 그 값은 §12.1 때문에 항상 비어 있다. 헤더가 테넌트가 되는 경로가 없으므로 교차 테넌트 읽기도 없다. 그러나 그것은 가드가 작동해서가 아니라 테넌트 기능 전체가 배선되지 않아서다. §12.1을 고치면서 이 가드를 함께 연결하지 않으면, 그때 노출이 생긴다.

이 패키지에는 WebCorsPolicyValidator(96줄)와 WebCsrfPolicyResolver(43줄)도 있고 둘 다 프로덕션 참조 0이다. 실제 CORS·CSRF 결정은 SecurityConfig가 Spring Security API로 직접 내린다. 같은 질문에 대한 두 번째 구현이 검증만 되고 쓰이지 않는다.

12.3 P3 — publicPathsRestrictedPathRule보다 먼저 등록되어, 넓은 공개 경로 하나가 관리 평면 규칙을 조용히 덮는다

§11.3. RestrictedPathRule의 javadoc은 이 규칙이 존재하는 이유를 "an application-level policy consulted later cannot recover from a transport that already let the request through"로 설명한다. 그런데 permitAll(publicPaths)이 그 규칙보다 먼저 등록되어 정확히 그 일을 한다.

실패 시나리오 — 운영자가 마이그레이션 중 SECURITY_PUBLIC_PATHS=/api/**를 설정한다. fileserver 관리 경로가 /api/ 아래 있으면 FileserverAdminPlaneConfiguration이 등록한 RestrictedPathRule은 도달하지 않고, 관리 평면이 무인증으로 열린다. 부팅은 아무 경고도 내지 않는다.

RestrictedPathRule의 생성자는 "a rule that requires nothing is weaker than the authenticated default it replaces"를 이유로 빈 권한 목록을 거부한다 — 규칙 자신이 약해지는 것은 막으면서, 규칙이 통째로 우회되는 것은 막지 않는다.

권고 — 제한 규칙을 publicPaths보다 먼저 등록하거나, 두 패턴 집합이 겹치면 부팅에서 거부한다. 후자가 이 leaf의 다른 게이트들과 형태가 같다.

12.4 P3/기록 — auth-mode 값 철자에 따라 컨텍스트가 시작하지 못한다

SecurityConfig:57-64의 세션 저장소 빈은 @ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session")로 게이트되고, :138sessionSecurityContextRepository.getObject()로 그것을 요구한다. 한편 SecuritySettings.authMode는 같은 프로퍼티를 enum으로 relaxed binding한다.

두 메커니즘의 허용 철자가 다르다. ca-skeleton.security.auth-mode=REDIS_SESSION이면 relaxed binding은 AuthenticationMode.REDIS_SESSION으로 묶어 else 분기로 보내지만, @ConditionalOnPropertyequalsIgnoreCase("redis-session")_- 차이로 일치하지 않아 빈이 없다 → getObject()NoSuchBeanDefinitionException으로 부팅을 실패시킨다.

fail-closed이고 부팅 시점이라 위험은 작다. 기록하는 것은 같은 프로퍼티에 대한 두 해석기가 서로 다른 문법을 갖는다는 점이다.

13. Sub-scope 03 완료 조건

  • denominator 44 / 44 FULL_READ (probe가 file_count=44 확인)
  • §8.1~§8.4 수행 — 도달성에서 P1 1건·P2 1건, 순서 비교에서 P3 1건, 기록 1건
  • 소스 미변경

Sub-scope 04 — ratelimit + admission + budget + */throttle (50 files, main 41 + test 9)

내부 상태: COMPLETE — 50 / 50 FULL_READ · 근거 evidence/raw/193-inbound-web-capacity-probes.txt (file_count=50), 패키지 도달성 지도 evidence/raw/192-inbound-web-package-reachability.txt

14. 무엇을 하는 코드인가

용량 보호 계층 전체 — 요청/응답 하드 바운드(budget), 부하 차단(admission), 속도 제한(ratelimit), 그리고 그것들을 전송에 붙이는 네 개 필터.

설계 논증이 정확하다. WebStableModuleADMISSIONRATELIMIT과 분리한 이유를 적는다 — "This module knows what the service is doing and nothing about who is calling; that separation is what keeps a capacity 503 from being reported as a quota 429."

WebMvcBudgetExceptionHandler는 필터와 나란히 있어야 하는 이유를 자기고발로 설명한다:

"The filter sees the cheap dimensions before dispatch and the response overrun after; a body bound crossed while the handler is reading the stream is thrown inside the dispatcher, which resolves it into a 500 before the filter's catch is ever reached. That is how the first draft of this feature answered 500 to an oversized chunked body while its unit tests were green."

그리고 §8.1(SS2)에서 본 교훈을 반영한다 — @ConditionalOnBean을 쓰지 않는 이유가 명시돼 있다: "on a component-scanned type @ConditionalOnBean is evaluated before the configuration that declares the bean has necessarily run, so the handler disappears without a word."

ratelimit의 세부도 촘촘하다. EdgeRateLimitTransportBridge가 주체를 VersionedEdgeSubjectPseudonymizer로 가명화하고, 클라이언트 IP는 remote-addr-only가 기본이며 forwarded-headers-trusted는 "trusted ingress only"로 표시된다. RateLimitWebConfig는 활성화된 브리지가 정확히 하나의 EdgeRateLimitPort를 요구하고 로컬 폴백을 절대 설치하지 않는다.

15. Negative-space probes — sub-scope 04

15.1 (8.1) 도달성 — 네 필터와 admission controller 의 등록 지점
-- WebMvcBudgetFilter
testkit/java/dev/caskeleton/webtestkit/BudgetFixtureApplication.java:43   FilterRegistrationBean<WebMvcBudgetFilter> budgetFilter()
-- WebFluxBudgetFilter
webfluxContractTest/.../testkit/budget/ReactiveBudgetFixtureApplication.java:38   WebFluxBudgetFilter budgetFilter()
-- WebMvcThrottleFilter
testkit/java/dev/caskeleton/webtestkit/ThrottleFixtureApplication.java:47  FilterRegistrationBean<WebMvcThrottleFilter> throttleFilter(...)
-- WebFluxThrottleFilter
webfluxContractTest/.../testkit/throttle/ReactiveThrottleFixtureApplication.java:43
-- SemaphoreAdmissionController
testkit/.../testkit/throttle/ThrottleFixtureSupport.java:38

다섯 개 전부 픽스처 애플리케이션에서만 생성된다. src/main에도 app-bootstrap에도 등록 지점이 없다. §16.1.

15.2 (8.2) 조건 형제 비교 — 속도 제한이 두 벌이다
메커니즘 배선 기본값
RateLimitInterceptorEdgeRateLimitTransportBridgeEdgeRateLimitPort RateLimitWebConfig(@Configuration)가 addInterceptors로 등록 app.rate-limit.enabled=${APP_RATE_LIMIT_ENABLED:false} → 비활성 시 RateLimitInterceptor.disabled(...)
WebRateLimiter 참조자는 두 throttle 필터뿐이고 그 둘이 미등록

MVC 인터셉터 경로는 배선돼 있고 옵트인이다(정상). WebRateLimiter 경로는 배선 자체가 없다. 같은 질문에 대한 두 구현 중 하나만 회로가 닫혀 있다.

WebFlux 쪽에는 인터셉터 대응물이 없다 — RateLimitWebConfigWebMvcConfigurer다. 따라서 리액티브 전송에는 어떤 속도 제한 경로도 없다.

15.3 (8.3) WebBudgetCatalog 소비자
$ grep -rn 'WebBudgetCatalog' src/main app-bootstrap/src/main | grep -v WebBudgetCatalog.java
mvc/autoconfigure/WebMvcPlatformAutoConfiguration.java:66      catalog 생성
webflux/autoconfigure/WebFluxPlatformAutoConfiguration.java:84 catalog 생성

두 자동설정이 각각 WebBudgetCatalog를 빈으로 등록하고 standard() 프로파일을 넣는다. 읽는 코드가 없다. 카탈로그를 소비할 필터가 등록되지 않았기 때문이다(§15.1).

15.4 (8.4) 게이트 프로퍼티가 존재하는가
$ grep -rn 'backend.web.budgets' --include=*.yml --include=*.yaml --include=*.properties --include=*.java .
main/.../mvc/budget/WebMvcBudgetExceptionHandler.java:40:@ConditionalOnProperty(prefix = "backend.web.budgets", name = "enabled", havingValue = "true")

자바 한 줄뿐이다. 어떤 application.yml에도 backend.web.budgets가 없고 matchIfMissing도 없으므로 이 핸들러는 기본 꺼짐이다. 그리고 켜더라도 그 생성자가 요구하는 BudgetProblemMapper 빈을 선언하는 코드가 main·app-bootstrap 어디에도 없다 — 참조자는 두 필터와 이 핸들러 자신뿐이고, 셋 다 빈 정의가 아니다.

16. Sub-scope 04 findings

16.1 P1 — 용량 보호 계층 전체(41 main files)가 자기 테스트 픽스처 안에서만 실행된다

프로덕션 배포에서 실제로 설치되는 것과 설치되지 않는 것:

능력 구현 프로덕션 등록
요청/응답 바이트 바운드 · 데드라인 WebMvcBudgetFilter(122) · WebFluxBudgetFilter(122) · BoundedHttpServletRequest(115) · BoundedHttpServletResponse(151) · BoundedServerWebExchange(79) · WebBudgetMeter(89) · WebRequestBudget(141) 없음
부하 차단(제한된 동시성 + 제한된 큐 + 503) SemaphoreAdmissionController(118) · AdmissionProfile(80) · AdmissionDecision(59) · AdmissionPermit(21) 없음
429 속도 제한 (WebRateLimiter 경로) WebMvcThrottleFilter(136) · WebFluxThrottleFilter(142) 없음
429 속도 제한 (인터셉터 경로) RateLimitInterceptorRateLimitWebConfig 있음, 기본 비활성(APP_RATE_LIMIT_ENABLED:false)
예산 초과 문제 문서 WebMvcBudgetExceptionHandler(84) 기본 꺼짐 + 의존 빈 미선언

즉 이 플랫폼을 그대로 배포하면 요청 본문 크기 상한도, 응답 크기 상한도, 요청 데드라인도, 동시성 상한도, 큐 상한도 없다. WebRequestBudget.standard()가 정의하고 WebBudgetCatalog가 담고 있는 값들은 아무도 읽지 않는다(§15.3).

실패 시나리오 — 배포된 API에 무제한 청크 본문이 도착한다. WebMvcBudgetFilter가 필터 체인에 없으므로 BoundedHttpServletRequest가 스트림을 감싸지 않고, WebBudgetMeter가 바이트를 세지 않는다. 컨테이너 기본값(Tomcat maxPostSizemultipart/form-data와 폼 인코딩에만 적용되고 임의 본문에는 적용되지 않는다) 외에 상한이 없다. 같은 요청에 대해 동시성 상한도 없으므로 SemaphoreAdmissionController가 내기로 되어 있던 503도 나오지 않는다.

왜 드러나지 않는가 — 그리고 이것이 이 모듈의 핵심 형태다. 이 leaf는 크로스 스택 게이트를 갖고 있다. webCrossStackParityTest가 Tomcat·Jetty·Reactor Netty 세 런타임의 와이어 계약을 비교하고, webJettyCompatTest·webFluxContractTest가 각각을 돌린다(§3.1). 그 레인들이 예산과 스로틀을 실제로 검증한다JettyWebBudgetIT · ReactiveWebBudgetIT · JettyWebThrottleIT · ReactiveWebThrottleIT가 있다.

그런데 그 IT들이 띄우는 것은 BudgetFixtureApplication · ThrottleFixtureApplication · ReactiveBudgetFixtureApplication · ReactiveThrottleFixtureApplication이고, 그 픽스처들이 FilterRegistrationBean으로 필터를 손수 등록한다. 레인은 "필터가 올바르게 동작하는가"를 세 런타임에서 증명하고, "플랫폼이 필터를 설치하는가"는 어디서도 묻지 않는다.

이것은 이 저장소가 다른 곳에서 이미 이름 붙인 형태다 — EndpointGuardCallSiteTest(notification)의 javadoc이 정확히 그 문장을 갖고 있다: "It proved that for months while the function had no caller... A green test on a control nothing invokes is the shape this repository keeps finding, and testing the helper again would not have caught it." 여기서는 그 형태가 41개 파일 규모로 반복된다.

권고WebMvcPlatformAutoConfiguration·WebFluxPlatformAutoConfiguration이 이미 WebBudgetCatalog를 등록하므로 자리는 있다. 네 필터와 SemaphoreAdmissionController, BudgetProblemMapper를 같은 자동설정에서 backend.web.budgets.enabled 게이트 아래 등록한다. 그리고 픽스처가 아니라 자동설정이 세운 컨텍스트에서 하나 이상의 IT를 돌린다 — 그것이 없으면 같은 상태가 다시 성립한다.

16.2 P2 — 리액티브 전송에는 속도 제한 경로가 하나도 없다

§15.2. 배선된 유일한 속도 제한기 RateLimitInterceptorWebMvcConfigurer.addInterceptors로 붙는 MVC 전용 장치다. WebFluxThrottleFilter가 리액티브 대응물이지만 등록되지 않는다(§16.1). webflux/autoconfigure/WebFluxPlatformAutoConfiguration의 11개 빈에도 없다.

따라서 backend.web.webflux로 리액티브 전송을 쓰는 배포는 APP_RATE_LIMIT_ENABLED=true를 설정해도 속도 제한이 걸리지 않는다. 프로퍼티는 받아들여지고 EdgeRateLimitPort 빈 유일성까지 검증되지만, 그것을 소비하는 인터셉터가 리액티브 체인에 존재하지 않는다.

MVC 배포에서는 §16.1과 무관하게 이 경로가 동작한다 — 이 발견은 리액티브 전송에 한정된다.

16.3 P3/기록 — WebMvcBudgetExceptionHandler를 켜면 컨텍스트가 시작하지 못한다

§15.4. backend.web.budgets.enabled=true를 설정하면 이 @RestControllerAdvice가 등록되고 생성자가 BudgetProblemMapper를 요구하는데, 그 빈을 선언하는 코드가 없다 → 미충족 의존성으로 부팅 실패.

바로 위 주석이 이 정확한 실패를 다른 원인으로 한 번 겪었다고 기록한다 — "the all-off deployment is not a servlet application at all, so the factory is absent while the property condition still matched, and the context failed to start on an unsatisfied dependency." 조건은 그때 고쳤고, 의존 빈은 여전히 없다. §16.1을 고치면 함께 닫힌다.

17. Sub-scope 04 완료 조건

  • denominator 50 / 50 FULL_READ (probe가 file_count=50 확인)
  • §8.1~§8.4 수행 — 도달성에서 P1 1건, 전송 비대칭에서 P2 1건, 기록 1건
  • 소스 미변경

Sub-scope 05 — idempotency + operation + operationasync + evidence (50 files, main 40 + test 10)

내부 상태: COMPLETE — 50 / 50 FULL_READ · 근거 evidence/raw/194-inbound-web-idempotency-probes.txt (file_count=50)

18. 무엇을 하는 코드인가

idempotency (9) — 전송 중립 멱등 승인. WebIdempotencyGate가 키를 읽고 범위와 의미 지문을 만들어 application-coreIdempotencyStorePort에 청구한다. 전송별 차이(헤더 읽기, 응답 쓰기)만 mvc/idempotencywebflux/idempotency에 있다 — "two copies of this logic would be two chances for one of them to answer a fingerprint mismatch with a replay."

지문 설계가 이 sub-scope에서 가장 잘 논증된 부분이다. SemanticRequestFingerprintFactory는 원시 바이트 해시를 거부하고 의미 지문을 만든다 — 네 입력(연산 · 정규화된 경로 식별자 · 정규 요청 모델 · 선택된 헤더). 경로 식별자를 넣는 이유가 구체적이다:

"POST /accounts/1/transfers and POST /accounts/2/transfers have identical bodies when the amount is the same, so a fingerprint over the body alone would let a caller's transfer from one account be answered with the receipt from another."

FingerprintHeaderPolicy는 짧은 허용목록이고 두 종류의 배제 이유를 구분한다 — traceparent·X-Request-Id·User-Agent는 재시도마다 달라지므로 넣으면 "a legitimate retry looks like a different request and the idempotency key stops working — silently, and only under the network conditions that make retries happen"; Authorization·Cookie는 다른 이유로 배제된다 — "the fingerprint is stored, and a stored digest of a credential is a credential in the database."

operation (11) — 라우트별 프로파일(멱등성 정책 · 승인 프로파일 · 캐시 정책 · 사전조건 정책 · 응답 프로파일 · 변경 종류)의 카탈로그.

operationasync (9) — 202 영속 작업 리소스. OperationAccessPolicy가 객체 수준 권한을 담당하고 "Absent and forbidden are answered identically on purpose"로 열거 오라클을 막는다.

evidence (6) — 3축 실행 증거(승인 · 애플리케이션 · 응답). WebExecutionEvidenceTracker가 main에서 실제로 생성된다 — 이 sub-scope에서 배선된 유일한 부분이다.

19. Negative-space probes — sub-scope 05

19.1 (8.1) 도달성 — 생성 지점
WebIdempotencyGate                 5 test  2 testkit
WebMvcIdempotentInvoker            1 test  1 testkit
WebFluxIdempotentInvoker           1 test  1 testkit
IdempotentResponseWriter           1 test  1 testkit
SemanticRequestFingerprintFactory  (동일)
OperationQueryService                      1 testkit
OperationResourceFactory                   1 testkit
WebExecutionEvidenceTracker        1 main            <- 유일한 배선

app-bootstrap이 이 leaf의 멱등성 타입을 참조하는 횟수: 0. app-bootstrap의 bootstrap/idempotency/* 다섯 파일은 애플리케이션 계층 멱등성(PostgreSQL 제공자)을 배선하며 웹 게이트와 접점이 없다.

IdempotencyKeySupport@Component라 스캔되지만 main·sample-portfolio에서 참조 0이다.

19.2 (8.2) durable-operation HTTP 표면의 두 게이트
// mvc/operation/OperationHttpController.java:35-38  (webflux/operation 도 동일)
@ConditionalOnProperty(prefix = "app.web-platform.durable-operations", name = "enabled",
                       havingValue = "true")           // matchIfMissing 없음 -> 기본 꺼짐
public OperationHttpController(OperationQueryService operations) { ... }

app.web-platform.durable-operations 문자열은 저장소의 어떤 yaml에도 없다. 그리고 켜더라도 생성자가 요구하는 OperationQueryService 빈을 선언하는 코드가 main·app-bootstrap에 없다(testkit에만 생성). §16.3과 같은 형태 — 게이트를 켜면 부팅이 실패한다.

19.3 (8.3) WebOperationCatalog를 읽는 쪽

두 자동설정이 InMemoryWebOperationCatalog를 빈으로 등록한다. 읽는 쪽은 셋이다:

admin/route/WebRouteInventory.java:50           requireRegisteredOperations(WebOperationCatalog)
advanced/functional/FunctionalRoutePolicyValidator.java:24
advanced/functional/FunctionalRouteRegistry.java:25

패키지 도달성 지도(192-...)에서 admin.routeadvanced.functional은 둘 다 in=0 ext=0이다 — 자기들도 아무도 부르지 않는다. 등록된 카탈로그 빈은 비어 있는 채로 아무도 읽지 않는다(연산을 등록하는 코드도 없다).

19.4 (8.4) 지문 정규화가 길이 프레이밍인가

SemanticRequestFingerprintFactory는 U+001F 한 글자를 구분자로 쓰고, 경로 변수는 SEP + name + "=" + value, 헤더는 SEP + name + ":" + value로 이어붙인다. 값에 대한 이스케이프나 길이 접두사가 없다.

같은 저장소의 다른 다이제스트들(notification NotificationCatalogException.update, messaging의 도메인 분리 상수)은 4바이트 길이 프레이밍을 쓰고, 그 이유를 "인접 필드 연결로 인한 충돌이 구조적으로 불가능"으로 적는다. §20.3.

20. Sub-scope 05 findings

20.1 P1 — 멱등 실행 계층과 durable-operation 표면이 픽스처에서만 조립된다

§19.1·§19.2. 이 sub-scope에서 프로덕션 컨텍스트에 들어가는 것은 WebExecutionEvidenceTracker(두 요청 필터가 만든다)와 빈 InMemoryWebOperationCatalog 둘뿐이다. 나머지 38개 main 파일 — 게이트, 두 invoker, 응답 writer, 지문 공장, 헤더 정책, 명령 인코더, 승인 판정, 응답 계획, 코덱, 그리고 operationasync 9종 전부 — 는 테스트와 testkit에서만 생성된다.

실패 시나리오 — 클라이언트가 Idempotency-Key를 붙여 결제 생성을 POST한다. 네트워크가 끊겨 같은 키로 재시도한다. WebIdempotencyGate가 필터 체인에도 인터셉터에도 컨트롤러 조언에도 없으므로 헤더는 읽히지 않고, 두 번째 요청은 첫 번째와 무관하게 그대로 실행된다. 결제가 두 번 생성된다. Idempotency-Key를 받아들이는 것처럼 보이는 API가 그것을 지키지 않으며, 헤더가 거부되지도 않으므로 클라이언트는 지켜졌다고 믿는다.

같은 형태의 반복 — SS3(요청 컨텍스트 생산자 없음), SS4(용량 계층 미등록)와 같다. 이 세 sub-scope에서 미조립된 main 파일은 41 + 38 + security 11 = 90개다.

왜 드러나지 않는가WebIdempotencyGate는 테스트 5곳·testkit 2곳에서 생성되고, testkit의 픽스처 애플리케이션이 그것을 손수 배선해 계약 레인에서 돌린다. SS4와 동일하게, 레인은 "게이트가 올바른가"를 증명하고 "플랫폼이 게이트를 설치하는가"는 묻지 않는다.

20.2 P3/기록 — durable-operation을 켜면 컨텍스트가 시작하지 못한다

§19.2. app.web-platform.durable-operations.enabled=true를 설정하면 두 컨트롤러가 등록되고 OperationQueryService 빈을 요구하는데 그 빈이 없다. §16.3(budgets)과 같은 형태이고 같은 수정으로 닫힌다.

20.3 P3 — 의미 지문이 길이 프레이밍 없이 구분자로 만들어진다

§19.4. 경로 변수 값과 헤더 값이 이스케이프 없이 구분자로 이어붙는다. 값 자체에 그 구분자가 들어가면(퍼센트 인코딩 %1F를 Spring이 디코딩해 @PathVariable로 전달한다) 서로 다른 두 요청이 같은 정규 문자열을 만들 수 있다 — 경로 변수가 둘 이상인 연산에서 하나를 통제하면 구성 가능하다.

악용 가치는 낮다. 멱등 레코드는 principal + tenant로 범위가 잡히므로 충돌시킬 수 있는 것은 자기 자신의 이전 요청뿐이고, 그것으로 얻는 것이 없다. 그리고 게이트 자체가 미조립이다(§20.1).

기록하는 이유는 일관성이다. 이 저장소는 다른 세 모듈에서 같은 문제를 길이 프레이밍으로 닫았고 그 이유를 명시했다. 여기서는 구분자를 골랐고 그 선택에 대한 근거가 없다. 값 앞에 길이를 붙이는 형태 하나면 닫힌다.

21. Sub-scope 05 완료 조건

  • denominator 50 / 50 FULL_READ (probe가 file_count=50 확인)
  • §8.1~§8.4 수행 — 도달성에서 P1 1건, 정규화에서 P3 1건, 기록 1건
  • 소스 미변경

Sub-scope 06 — pagination + cursor + conditional + cache + versioning (54 files, main 42 + test 12)

내부 상태: COMPLETE — 54 / 54 FULL_READ · 근거 evidence/raw/195-inbound-web-representation-probes.txt (file_count=54)

22. 무엇을 하는 코드인가

앞의 세 sub-scope와 성격이 다르다. 여기 있는 것은 플랫폼이 설치하는 필터가 아니라 소비 모듈의 컨트롤러가 부르는 라이브러리다. 그래서 "등록되지 않았다"가 곧바로 결함은 아니고, 물어야 할 것은 "부르는 쪽이 있는가"다.

pagination (19) — 키셋 페이지네이션의 어휘 전체. 정렬 필드·필터 필드·필터 연산자·프로젝션 프로파일을 각각 카탈로그로 닫고, 커서는 HMAC으로 서명한다(HmacWebCursorCodec + WebCursorKeyRing). FilterFingerprint가 커서와 질의 어휘를 묶어 커서 재사용이 다른 필터로 넘어가지 못하게 한다.

cursor (2) — 두 번째 커서 코덱. paginationWebCursorCodec/HmacWebCursorCodec과 별개다.

conditional (10) — ETag 값과 사전조건. ETags(91) · EntityTag(83) · EntityTagCodec(72) · 읽기용 ConditionalReadEvaluator · 쓰기용 MutationPreconditionEvaluator.

cache (4, 310 LOC) — 발행된 캐시 프로파일과 지시자, Vary 규칙. WebStableModule이 CORE 순도로 선언하고 그 근거를 적는다: "Keeping it free of Spring is what lets the same profile be applied by the servlet writer, the reactive writer and the OpenAPI document without three renderings of it."

versioning (7) — 주 버전 해석, 폐기 정책, Deprecation/Sunset 헤더 작성.

23. Negative-space probes — sub-scope 06

23.1 (8.1) 도달성 — 라이브러리 타입의 소비자
HmacWebCursorCodec              5 test
CursorCodec                     2 test
WebCollectionRequestParser      1 test
ConditionalReadEvaluator        1 test
MutationPreconditionEvaluator   1 test
ETags                           3 sample   12 test      <- 유일한 소비 모듈 사용
WebCachePolicyCatalog          10 test
WebVaryPolicy                   4 main      8 test      <- 참조자는 cache 패키지 내부
ApiVersionCatalog               4 test
DeprecationHeaderWriter         1 test
PathApiVersionResolver          2 test
WebPageSizePolicy               1 test

다섯 패키지 중 소비 모듈이 실제로 부르는 것은 ETags 하나다 — sample-portfolioWorkLogController가 세 곳에서 쓴다(:144 If-None-Match 비교, :213 버전에서 약한 ETag 생성, :226 If-Match 검사).

나머지는 전부 테스트 전용이다. 라이브러리이므로 그 자체가 결함은 아니지만, 페이지네이션 어휘 19개 파일·버전 관리 7개 파일이 한 번도 컨트롤러에 붙어 본 적이 없다는 사실은 기록해 둘 값이 있다 — 이 저장소가 다른 곳에서 "타입은 있고 호출자가 없다"를 반복해서 결함으로 취급했기 때문이다.

23.2 (8.2) 조건 형제 비교 — 캐시 정책이 두 벌이다

배선된 것:

// filter/CacheControlFilter.java  (24 lines)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 20)
public class CacheControlFilter extends OncePerRequestFilter {
  static final String DEFAULT_CACHE_CONTROL = "no-store";
  static final String DEFAULT_VARY = "Accept, Accept-Encoding, Authorization";
  protected void doFilterInternal(...) {
    response.setHeader(ApiHeaders.CACHE_CONTROL, DEFAULT_CACHE_CONTROL);
    response.setHeader(ApiHeaders.VARY, DEFAULT_VARY);
    chain.doFilter(request, response);
  }
}

@Component이므로 컴포넌트 스캔이 잡고 Spring이 Filter 빈을 체인에 넣는다. 모든 응답에 no-store를 붙인다.

배선되지 않은 것: cache 패키지 4개 파일 310 LOC. web.cache. 패키지를 참조하는 파일이 자기 패키지 밖에 0개다.

SecurityConfig:80이 Spring Security의 기본 캐시 헤더 작성기를 끄면서 그 이유를 적는다 — "CacheControlFilter owns the cache header policy". 소유자는 24줄짜리 상수 두 개이고, 프로파일·지시자·Vary 규칙을 갖춘 310줄은 소유하지 않는다.

23.3 (8.3) 중복 메커니즘 — 커서 코덱도 두 벌

pagination/WebCursorCodec(인터페이스) + pagination/HmacWebCursorCodec(135, 서명된 구현) + pagination/WebCursorPayload + pagination/WebCursorKeyRing, 그리고 별도로 cursor/CursorCodec(100) + cursor/CursorException. WebStableModule은 둘을 다른 모듈로 선언한다(CURSOR = "Opaque keyset cursor encoding and its failure type", PAGINATION). 둘 다 프로덕션 소비자가 없어 어느 쪽이 정본인지 코드로는 판정할 수 없다.

23.4 (8.4) no-store와 조건부 읽기의 충돌

WorkLogController.getOne은 ETag를 계산하고 If-None-Match가 맞으면 304를 낸다. 그리고 Cache-Control을 설정하지 않는다 — sample-portfolio main 전체에서 CacheControl 참조 0. 따라서 CacheControlFilterno-store가 그대로 남는다. §24.1.

24. Sub-scope 06 findings

24.1 P2 — 배선된 캐시 필터의 no-store가 배선된 조건부 읽기 경로를 무력화하고, 둘을 조정하려고 만든 패키지는 참조 0이다

CacheControlFilter모든 응답Cache-Control: no-store를 붙인다. RFC 9111에서 no-store는 "어떤 캐시에도 저장하지 말라"는 지시다. 규격을 지키는 클라이언트는 응답을 보관하지 않으므로, 나중에 그 리소스에 대해 If-None-Match를 보낼 근거(저장된 표현과 그 ETag)를 갖지 못한다.

그런데 같은 배포에서 WorkLogController.getOne은 ETag를 발행하고 If-None-Match 분기를 갖는다:

// sample-portfolio WorkLogController.java:144-148
if (ETags.matches(ifNoneMatch, etag)) {
  return ResponseEntity.status(HttpStatus.NOT_MODIFIED).eTag(etag).build();
}
return ResponseEntity.ok().eTag(etag).body(...);

실패 시나리오 — 대역폭을 아끼려고 조건부 GET을 구현한 클라이언트가 첫 응답에서 ETag: W/"3"Cache-Control: no-store를 함께 받는다. 규격대로 저장하지 않으므로 다음 요청에 If-None-Match를 붙일 수 없고, 서버는 매번 전체 표현을 보낸다. 304 분기는 no-store를 무시하는 클라이언트에서만 실행된다. ETag 계산·비교 코드는 매 요청 실행되고 절감은 발생하지 않는다.

쓰기 쪽 If-Match(낙관적 동시성, :226)는 영향이 없다 — 클라이언트가 방금 받은 ETag를 같은 세션에서 되돌려 보내므로 캐시 저장이 필요 없다. 결함은 읽기 경로에 한정된다.

조정하려고 만든 것이 있다. cache 패키지가 정확히 이 문제를 풀도록 설계돼 있다 — 이름 붙은 프로파일(WebCachePolicyCatalog, 120줄), 지시자 집합(WebCachePolicy, 72줄), 그리고 Vary 규칙(WebVaryPolicy, 90줄). WebStableModule의 근거가 "the same profile be applied by the servlet writer, the reactive writer and the OpenAPI document"인데, 세 적용자 중 어느 것도 존재하지 않는다(§23.2).

권고CacheControlFilter가 상수 두 개 대신 WebCachePolicyCatalog를 읽게 하고, 연산별 프로파일이 없을 때만 no-store로 떨어지게 한다. 그러면 no-store 기본값(민감한 API에 옳다)과 조건부 읽기가 공존할 수 있다.

24.2 P3/기록 — 커서 코덱과 페이지네이션 어휘 26개 파일에 소비자가 없다

§23.1·§23.3. pagination 19 + cursor 2 + versioning 7 = 28개 파일 중 소비 모듈이 부르는 것은 0개다(ETagsconditional에 있다). 서명된 커서(HmacWebCursorCodec + WebCursorKeyRing), 닫힌 정렬/필터/프로젝션 어휘, 폐기 헤더 작성기가 전부 테스트에서만 실행된다.

라이브러리 패키지이므로 SS4·SS5의 P1과 성격이 다르다 — 플랫폼이 설치해야 할 것을 설치하지 않은 것이 아니라, 채택자가 아직 쓰지 않은 것이다. 다만 커서 코덱이 두 벌(§23.3)이라는 사실은 소비자가 생기는 시점에 결정을 요구하며, 지금은 어느 쪽이 정본인지 코드가 말하지 않는다.

24.3 P3/기록 — UnsupportedApiVersionException은 main에서 던져지지 않는다

throw 지점 전수: PageValidationException 10 · WebCursorException 10 · CursorException 7 · UnsupportedQueryVocabularyException 6 · SunsetViolationException 3 · WebPreconditionFailedException 1 · UnsupportedApiVersionException 0.

versioning 패키지가 정의하고 어디서도 만들지 않는 실패 타입이다. PathApiVersionResolver(60줄)가 버전을 해석하지만 미지원 버전을 이 예외로 거부하지 않는다.

25. Sub-scope 06 완료 조건

  • denominator 54 / 54 FULL_READ (probe가 file_count=54 확인)
  • §8.1~§8.4 수행 — 캐시 충돌에서 P2 1건, 기록 2건
  • 소스 미변경

Sub-scope 07 — http + json + advanced/codec + openapi (45 files, main 34 + test 11)

내부 상태: COMPLETE — 45 / 45 FULL_READ · 근거 evidence/raw/196-inbound-web-codec-probes.txt (file_count=45)

26. 무엇을 하는 코드인가

http (12) — 이 leaf에서 가장 잘 연결된 패키지다(패키지 지도 in=9 ext=2). 헤더 이름, 경로 정규형, 메서드 허용목록, URI 정책, 그리고 외부 URL 생성기(ExternalUriBuilder/ExternalUriPolicy/ExternalOrigin/ExternalPrefix).

json (4) — 요청 본문을 읽는 엄격한 리더. 두 파일의 논증이 이 sub-scope의 핵심이다.

BoundedJsonFactory는 제한을 파싱 후가 아니라 스트리밍 파서에 거는 이유를 적는다: "A depth limit applied to a parsed tree has already paid for the tree; a nesting bomb is cheap to send and expensive to hold, so the only limit that helps is one the streaming parser refuses to exceed."

WebObjectMapperFactory는 Jackson 3(tools.jackson)을 쓰는 이유를 자기고발로 적는다: "An earlier version of this class used com.fasterxml, which is also on this classpath — the mapper was correct, strict, unit tested, and never consulted by the framework for a single request."

WebJsonProfile은 여덟 개 관용 기본값을 각각 왜 끄는지 설명한다 — 중복 키("the last one wins and the client believes the first one did"), 미지 속성("a typo'd field name is silently dropped and the request 'succeeds' without doing what was asked"), 후행 토큰("a concatenated second document is ignored").

advanced/codec (6) — XML·CBOR 표현. SecureXmlInputFactory가 DTD와 외부 엔티티를 끄고 거부하는 리졸버까지 단다. javadoc이 정확하다 — "Neither produces an error when it fires; the parse succeeds and the document contains something it should not." secure(XMLInputFactory) 검사기를 별도로 두는 이유도 명시된다: "The failure this guards is a configuration path that constructs its own factory and never reaches create()."

openapi (7) + advanced/openapi (5) — 문서 생성, 스키마 기여, 파괴적 변경 정책, 릴리스 게이트.

27. Negative-space probes — sub-scope 07

27.1 (8.1) 도달성 — WebJsonProfile 여덟 필드 중 강제되는 것
rejectUnknownProperties   json/WebObjectMapperFactory.java:66
rejectDuplicateKeys       json/BoundedJsonFactory.java:40
rejectTrailingTokens      json/WebObjectMapperFactory.java:68
caseSensitiveEnums        json/WebObjectMapperFactory.java:69, :71
rejectScalarCoercion      json/WebObjectMapperFactory.java:75
maxDepth                  json/BoundedJsonFactory.java:37   -> StreamReadConstraints.maxNestingDepth
maxArrayElements          (없음)
maxStringBytes            json/BoundedJsonFactory.java:38   -> StreamReadConstraints.maxStringLength

일곱은 강제되고 하나는 읽는 코드가 없다. §28.1.

27.2 (8.2) 조건 형제 비교 — OpenApiCustomizer 가 두 개다

배선된 것 — config/OpenApiContractConfig(37줄, @Configuration)가 익명 람다 OpenApiCustomizer 하나를 빈으로 등록한다. 하는 일은 ApiError.details 스키마를 ObjectSchema로 되돌리는 것 한 가지다.

배선되지 않은 것 — openapi/WebOpenApiCustomizer(74줄)와 그것이 쓰는 ProblemSchemaContributor(88) · CursorSchemaContributor(47) · WebOpenApiProfile(72) · WebOpenApiBreakingPolicy(187) · WebOpenApiReleaseGate(100) · WebOpenApiDiffResult(39). 합 607줄. 빈으로 등록하는 코드가 main·app-bootstrap에 없고, 참조는 자기들끼리와 테스트뿐이다.

즉 springdoc이 생성하는 문서에는 RFC 9457 problem 스키마도 커서 스키마도 기여되지 않는다 — 그 기여자들이 커스터마이저에 도달하지 않기 때문이다. SS2에서 확인한 "problem 계약이 문서에 없다"(§7.3)와 같은 방향의 사실이 스키마 쪽에서도 성립한다.

27.3 (8.3) XML/CBOR 표현의 런타임 배선

build.gradle이 두 백엔드를 compileOnly로 두고 그 이유를 길게 적는다(§2) — implementation이었을 때 "silently began parsing application/xml request bodies... an XXE surface nobody chose"였기 때문이다. 의도된 설계다.

그런데 그 잭슨 백엔드를 배포가 추가하더라도 메시지 컨버터를 등록하는 코드가 없다. WebXmlMapperFactory·WebCborMapperFactory·RepresentationNegotiationPolicy를 참조하는 파일은 자기 패키지와 테스트뿐이고, 두 자동설정(MVC 12빈 · WebFlux 11빈)에도 없다. WebRepresentation.available()이 "absent backend를 문장으로 바꾼다"는 장치는 그 문장을 낼 호출자가 없다.

27.4 (8.4) maxStringBytes 가 무엇에 적용되는가
// BoundedJsonFactory.java:36-39
.streamReadConstraints(StreamReadConstraints.builder()
    .maxNestingDepth(profile.maxDepth())
    .maxStringLength(profile.maxStringBytes())
    .build());

Jackson의 maxStringLength문자 수 상한이고 필드 이름은 maxStringBytes다. UTF-8에서 문자당 최대 4바이트이므로 선언된 1,048,576이 실제로는 최대 4 MiB를 허용한다. §28.3.

28. Sub-scope 07 findings

28.1 P2 — maxArrayElements가 선언만 되고 강제되지 않으며, 바이트 예산 백스톱도 없다

WebJsonProfile이 필드를 선언하고 문서화한다:

 * @param maxArrayElements most elements accepted in one array
public static WebJsonProfile strict() {
  return new WebJsonProfile(true, true, true, true, true, 64, 100_000, 1_048_576);
}                                                             ^^^^^^^

읽는 코드가 저장소 전체에 0개다(§27.1). Jackson 3의 StreamReadConstraints에는 배열 원소 수 상한이 없으므로 BoundedJsonFactory가 넘길 자리도 없고, 매퍼 쪽에서도 검사하지 않는다.

실패 시나리오 — 클라이언트가 [0,0,0, … ] 형태로 원소 1억 개짜리 배열을 보낸다. maxNestingDepth(64)는 깊이만 보고, maxStringLength는 문자열 하나의 길이만 본다. 파서는 배열 전체를 스트리밍으로 읽어 List로 materialize한다. 200 MB 남짓의 요청 본문이 수 GB의 힙이 된다.

백스톱이 없다. 이 위험을 막을 상위 장치가 WebMvcBudgetFilter의 요청 바이트 상한인데, SS4에서 확인했듯 그 필터는 등록되지 않는다(§16.1). 서블릿 컨테이너의 기본값도 임의 본문에는 적용되지 않는다. 따라서 지금 이 플랫폼에는 JSON 배열 원소 수에 대한 상한이 어느 계층에도 없다.

BoundedJsonFactory의 javadoc이 정확히 이 상황을 서술한다 — "a nesting bomb is cheap to send and expensive to hold, so the only limit that helps is one the streaming parser refuses to exceed." 깊이와 문자열 길이는 그 원칙대로 걸었고, 원소 수는 값만 선언하고 걸지 않았다.

권고 — Jackson에 해당 제약이 없으므로 파서 수준에서는 걸 수 없다. @Size(max = …) 를 컬렉션 필드에 요구하는 계약 규칙(이미 sample-portfolio의 BatchCreateRequest@Size(max = MAX_BATCH_SIZE)로 그렇게 한다)으로 옮기거나, maxArrayElements 필드를 제거해 강제되지 않는 한도가 강제되는 것처럼 읽히지 않게 한다. 어느 쪽이든 §16.1의 바이트 예산을 배선하는 것이 실질적인 백스톱이다.

28.2 P3/기록 — OpenAPI 기여자 607줄이 커스터마이저에 도달하지 않는다

§27.2. 등록된 커스터마이저는 익명 람다 하나이고, 이 leaf가 작성한 커스터마이저와 두 스키마 기여자·프로파일·파괴적 변경 정책·릴리스 게이트는 빈이 되지 않는다. /v3/api-docs가 내는 문서에는 problem 스키마도 커서 스키마도 없다.

WebOpenApiReleaseGate(100줄)와 WebOpenApiBreakingPolicy(187줄)는 성격이 다르다 — 릴리스 게이트는 빌드 태스크나 테스트에서 부르는 것이 자연스럽고, 실제로 WebOpenApiReleaseGateTest·WebOpenApiSnapshotTest가 부른다. 문제는 커스터마이저와 두 기여자다.

28.3 P3/기록 — maxStringBytes가 바이트가 아니라 문자에 적용된다

§27.4. 이름과 문서는 바이트를 말하고, Jackson의 maxStringLength는 문자를 센다. 비ASCII 본문에서 실효 상한이 선언값의 최대 4배가 된다. 같은 레코드의 maxDepth·maxArrayElements는 단위 모호성이 없으므로 이 하나만 이름을 maxStringChars로 바꾸면 닫힌다.

29. Sub-scope 07 완료 조건

  • denominator 45 / 45 FULL_READ (probe가 file_count=45 확인)
  • §8.1~§8.4 수행 — 미강제 한도에서 P2 1건, 기록 2건
  • 소스 미변경

Sub-scope 08 — observability + proxy + filter + mvc/*·webflux/* 잔여 (53 files, main 38 + test 15)

내부 상태: COMPLETE — 53 / 53 FULL_READ · 근거 evidence/raw/197-inbound-web-observability-probes.txt

30. 무엇을 하는 코드인가

배선된 필터 다섯. 이 sub-scope에 이 leaf의 실제 필터 체인이 전부 있다:

필터 등록 순서
mvc/filter/WebMvcRequestIdFilter MVC 자동설정 @Bean HIGHEST_PRECEDENCE + 10
mvc/filter/WebMvcEvidenceFilter MVC 자동설정 @Bean
filter/CacheControlFilter @Component HIGHEST_PRECEDENCE + 20
filter/RequestLoggingFilter @Component 없음 → LOWEST_PRECEDENCE
webflux/context/WebFluxRequestContextFilter WebFlux 자동설정 @Bean HIGHEST_PRECEDENCE + 10

observability (12) — MDC 키, 헤더 위생, 접근 로그·감사 이벤트 모델, 지표 태그 카디널리티 정책. RequestLoggingFilter가 인증된 주체를 UserPrincipalPseudonymizerPort로 가명화한 뒤에만 MDC에 넣는다 — "The raw idpUserId() is never written to MDC or logs."

proxy (4, 421 LOC) — 신뢰 프록시 정책(TrustedProxyPolicy 161), 정규화된 forwarded 헤더(NormalizedForwardedHeaders 158), 위생기(ForwardedHeaderSanitizer 72).

webflux/guard (2) — 리액티브 체인에서 블로킹 호출을 탐지.

advanced/mvc·advanced/webflux (12) — 스트리밍 쓰기, 연결 끊김 탐지, SSE 어댑터와 하트비트, 느린 소비자 종료, 가상 스레드 설정. 이 중 MvcStreamingExecutorConfigurationVirtualThreadMvcConfiguration@Configuration + @ConditionalOnProperty + @ConditionalOnWebApplication으로 게이트된 실제 배선 지점이다.

31. Negative-space probes — sub-scope 08

31.1 (8.2) 조건 형제 비교 — X-Request-Id에 대해 배선된 두 필터가 반대 정책을 쓴다

두 필터 모두 서블릿 배포에서 등록되고 둘 다 X-Request-Id 응답 헤더를 쓴다.

// mvc/filter/WebMvcRequestIdFilter.java:105-112   (기본 trustInboundRequestId = false)
private WebRequestId resolveRequestId(HttpServletRequest request) {
  if (!trustInboundRequestId) {
    return new WebRequestId(UUID.randomUUID().toString());   // 클라이언트 값을 보지 않는다
  }
  return sanitized(request.getHeader(REQUEST_ID_HEADER)) ...
}
// filter/RequestLoggingFilter.java:57-59, 91-95
String requestId = resolveOrGenerate(req.getHeader(HEADER_REQUEST_ID));   // 항상 클라이언트 값을 본다
res.setHeader(HEADER_REQUEST_ID, requestId);
...
private static String resolveOrGenerate(String inbound) {
  String clean = HeaderSanitizer.sanitize(inbound, MAX_ID_LENGTH);
  return (clean == null || clean.isBlank()) ? UUID.randomUUID().toString() : clean;
}

순서상 WebMvcRequestIdFilter(HIGHEST_PRECEDENCE + 10)가 먼저 돌아 새 UUID를 헤더에 쓰고, RequestLoggingFilter(LOWEST_PRECEDENCE)가 나중에 돌아 클라이언트가 보낸 값으로 덮어쓴다. MDC의 request_id와 접근 로그도 클라이언트 값이다. §32.1.

31.2 (8.1) 도달성 — forwarded 헤더 신뢰 정책
TrustedProxyPolicy            6 test  1 testkit
NormalizedForwardedHeaders    4 main  8 test          <- main 참조자는 proxy 패키지 내부
ForwardedHeaderSanitizer      2 test  1 testkit

proxy 패키지 421 LOC이 프로덕션 조립에 들어가지 않는다. 실제로 forwarded 헤더를 해석하는 것은 Spring Boot의 server.forward-headers-strategy=framework(app-bootstrap application.yml:321 기본값)가 등록하는 ForwardedHeaderFilter/ForwardedHeaderTransformer이고, 그것은 피어가 신뢰된 프록시인지 검사하지 않는다. §32.2.

31.3 (8.3) 중복 메커니즘 — 상관 식별자가 세 벌이다
메커니즘 헤더 저장 위치
WebMvcRequestIdFilter X-Request-Id, traceparent 요청 속성(WebRequestId/WebTraceId)
RequestLoggingFilter X-Request-Id, X-Correlation-Id, traceparent MDC
WebFluxRequestContextFilter X-Request-Id, traceparent Reactor context

세 번째는 전송이 달라 공존이 정상이다. 앞의 둘은 같은 서블릿 체인에서 같은 헤더를 두 번 처리한다. traceparent도 마찬가지로 두 번 파싱되며, RequestLoggingFilter는 응답에도 traceparent를 쓰고(:68) WebMvcRequestIdFilter는 쓰지 않는다.

31.4 (8.4) ExternalRequestContext.prefix 는 항상 비어 있다
// webflux/context/WebFluxRequestContextFilter.java:140-146
private ExternalRequestContext externalRequest(ServerHttpRequest request) {
  var uri = request.getURI();
  String scheme = uri.getScheme() == null ? "http" : uri.getScheme();
  int port = uri.getPort() > 0 ? uri.getPort() : ("https".equalsIgnoreCase(scheme) ? 443 : 80);
  String host = uri.getHost() == null ? "localhost" : uri.getHost();
  return new ExternalRequestContext(scheme, host, port, "");     // prefix 하드코딩
}

forward-headers-strategy=framework 덕분에 scheme·host·port는 외부 값이 맞다. prefix만 항상 빈 문자열이다 — ExternalRequestContext에서 검증이 가장 정교한 필드(슬래시로 시작·끝나지 않음·.. 순회 금지)가 어떤 값도 받지 않는다. ForwardedHeaderTransformerX-Forwarded-Prefix를 경로에 접어 넣으므로 기능적 손실은 없고, 필드가 죽어 있다.

observabilityWebAuditPublisher는 참조 0이다(인터페이스, 구현도 호출자도 없음). WebAccessLogger는 main 참조 1(자기 패키지 내부)뿐이고, 실제 접근 로그는 RequestLoggingFilter가 SLF4J로 직접 쓴다 — 여기서도 모델과 구현이 갈린다.

32. Sub-scope 08 findings

32.1 P2 — 요청 식별자를 클라이언트가 고를 수 없다는 정책이, 뒤에 도는 다른 배선 필터에 의해 뒤집힌다

§31.1. WebMvcRequestIdFilter의 javadoc이 그 정책의 이유를 적는다:

"Trust is also configurable and defaults to off for the request id. A caller that can choose its own request id can make two different requests share one identity, which is how a support investigation ends up reading somebody else's exchange."

그리고 자동설정은 그 기본값을 그대로 쓴다 — WebMvcPlatformSettings.trustInboundRequestId가 기본 false다. 그러나 같은 컨텍스트의 RequestLoggingFilter가 항상 인바운드 헤더를 채택하고, 순서상 나중이라 응답 헤더를 덮어쓴다.

실패 시나리오 — 클라이언트가 서로 다른 100개 요청에 X-Request-Id: shared-id를 붙여 보낸다. 응답은 전부 X-Request-Id: shared-id를 돌려주고, 접근 로그 100줄과 MDC 100건이 같은 request_id를 갖는다. 지원 조사에서 그 id로 검색하면 서로 다른 호출자의 100개 교환이 함께 나온다 — 인용된 javadoc이 서술한 바로 그 결과다.

로그 인젝션은 아니다. HeaderSanitizer.sanitize< 0x20 문자를 전부 제거하고 200자로 자른다(0x7F와 U+2028/U+2029는 남지만 SLF4J 한 줄 로그에서는 개행이 아니다).

세 번째 사실이 이것을 더 뚜렷하게 만든다 — 두 필터 중 요청 컨텍스트에 값을 넣는 쪽은 WebMvcRequestIdFilter이고, 그 값은 SS3(§12.1)에서 확인했듯 아무도 읽지 않는다. 실제로 관측 가능한 곳(응답 헤더 · MDC · 접근 로그)에 도달하는 값은 전부 RequestLoggingFilter의 것, 즉 클라이언트가 고른 것이다.

권고 — 하나를 남긴다. RequestLoggingFilterWebMvcRequestIdFilter가 요청 속성에 넣은 값을 읽게 하면(WebMvcRequestIdFilter.requestId(request)가 이미 그 접근자다) 정책이 한 곳에 남고 MDC·로그·응답 헤더가 일치한다.

32.2 P2 — forwarded 헤더 신뢰 판정이 Nginx 설정에만 있고, 그것을 위해 쓴 Java 정책 421 LOC은 배선되지 않는다

server.forward-headers-strategy=framework(기본값)에서 Spring이 X-Forwarded-Proto·X-Forwarded-Host·X-Forwarded-Port·X-Forwarded-Prefix보낸 피어가 누구든 반영한다. 그 값이 request.getURI()를 바꾸고, 그것이 ExternalRequestContext가 되고(§31.4), 그것으로 Location 헤더와 페이지네이션 링크가 만들어진다.

스푸핑을 막는 것은 nginxProxyTest 레인이 증명하는 Nginx 설정이다:

NginxProxyContractIT:63   attackerCannotOverrideForwardedHost()      X-Forwarded-Host: evil.example
NginxProxyContractIT:79   attackerCannotDowngradeTheForwardedScheme()
NginxProxyContractIT:93   attackerCannotForgeTheClientAddress()
NginxProxyContractIT:143  clientCannotInjectAPrefix()
   // "X-Forwarded-Prefix is set per location, so a client's value is replaced."

이 보증의 근거는 nginxProxyTest/resources/nginx/proxy_headers.conf가 location마다 헤더를 덮어쓴다는 사실이다. 애플리케이션은 검사하지 않는다.

TrustedProxyPolicy(161줄, CIDR 기반 피어 허용목록)가 애플리케이션 쪽 검사를 위해 존재하고, 프로덕션에서 생성되지 않는다. testkit의 ProxyFixtureController:53TrustedProxyPolicy.of("10.0.0.0/8", …)를 직접 만들어 픽스처에 붙인다 — SS4·SS5와 같은 형태다.

실패 시나리오 — 배포가 그 Nginx 설정을 쓰지 않거나(다른 인그레스, 서비스 메시, k8s 내부에서 파드 IP로 직접 도달), 인그레스를 우회하는 경로가 하나라도 있으면, 클라이언트가 X-Forwarded-Host: evil.example을 보내 그 요청이 만드는 모든 절대 URL을 자기 도메인으로 돌린다. 비밀번호 재설정 링크나 Location 헤더가 그 URL을 담으면 그대로 피싱 벡터가 된다.

이것을 방어로 쓰는 것 자체는 정당하다 — 인그레스에서 덮어쓰는 것이 표준 관행이다. 기록하는 것은 두 가지다: (1) 그 의존이 코드나 문서에 명시돼 있지 않고 레인의 .conf 파일에만 있다, (2) 애플리케이션 쪽 이중 방어로 쓰라고 421줄을 작성해 두고 연결하지 않았다.

권고TrustedProxyPolicyforward-headers-strategy 앞단에 배선하거나(피어가 목록 밖이면 forwarded 헤더를 버린다), 최소한 README에 "이 플랫폼은 인그레스가 X-Forwarded-*를 덮어쓴다고 전제한다"를 명시하고 proxy 패키지를 제거한다. 지금 상태는 그 전제를 아무 데도 적지 않은 채 그것을 대체할 코드를 갖고 있다.

32.3 P3/기록 — ExternalRequestContext.prefix가 항상 빈 문자열이고 WebAuditPublisher는 참조 0이다

§31.4. prefix 검증 로직(슬래시 규칙 · .. 순회 거부)은 어떤 값도 받지 않는다. WebAuditPublisher는 인터페이스이고 구현도 호출자도 없다 — 감사 이벤트 모델(WebAuditEvent 55줄 · WebAuditAction 41줄)이 발행 경로 없이 존재한다.

33. Sub-scope 08 완료 조건

  • denominator 53 / 53 FULL_READ
  • §8.1~§8.4 수행 — 정책 충돌에서 P2 1건, 신뢰 경계에서 P2 1건, 기록 1건
  • 소스 미변경

Sub-scope 09 — advanced/** (stream · patch · functional · virtualthread · blockingbridge · release) (65 files, main 52 + test 13)

내부 상태: COMPLETE — 65 / 65 FULL_READ · 근거 evidence/raw/198-inbound-web-advanced-probes.txt

34. 무엇을 하는 코드인가

Stable 밖의 능력들. build.gradle이 이들의 위치를 정확히 규정한다:

"Every capability is off unless a deployment names it, so none of them is exercised by anything a production deployment runs — which makes a lane that runs them all the only place a break is noticed before whoever enables it notices."

즉 이 sub-scope의 파일들이 프로덕션 컨텍스트에 없는 것은 설계대로이고, SS4·SS5의 P1과 성격이 다르다. 여기서 물어야 할 것은 "배포가 켜기로 하면 켜지는가"다.

advanced (2) — 능력 카탈로그. WebAdvancedFeature enum이 능력마다 하나씩 플래그를 갖고, 왜 하나로 묶지 않는지 적는다:

"One flag per capability, not one for 'advanced'. They have nothing in common operationally: virtual threads change how every request is scheduled, streaming changes how long a response holds a connection, XML adds a parser with a decades-long history of entity-expansion attacks. A single switch would make those one decision, and a deployment that wanted the first would be given the third."

WebAdvancedFeatureFlags는 "무엇이 켜져 있는가"를 운영자가 출력할 수 있는 하나의 값으로 만든다.

advanced/patch (13, 886 LOC) — RFC 6902 JSON Patch와 RFC 7396 Merge Patch. JsonPointerAuthorization(95)과 PatchFieldAuthorization(69)이 포인터 단위 권한을 담당한다 — 패치 문서가 권한 밖 필드를 건드리지 못하게 하는 부분으로, 이 패키지에서 가장 값이 큰 코드다.

advanced/stream + encoding + replay (25) — 스트림 세션 레지스트리, 종료 정책, 증거, NDJSON·RFC 7464 프레이밍, 그리고 재개 커서와 중복/누락 가드(GapAndDuplicateGuard).

advanced/virtualthread (3) — 승인 가드와 프로파일. VirtualThreadProfile.requiredObservations()가 운영자가 봐야 할 네 신호를 문장으로 적어 둔다("jdk.VirtualThreadPinned JFR events: a synchronized block held across a blocking call pins the carrier thread, and enough pinned carriers is a deadlock the thread dump does not obviously show").

advanced/release (2) — 승격 게이트와 릴리스 매니페스트.

35. Negative-space probes — sub-scope 09

35.1 (8.4) 카운트 드리프트 — 선언된 능력 11개, 활성화 게이트 2개

WebAdvancedFeature의 상수:

MVC_VIRTUAL_THREADS · WEBFLUX_BLOCKING_BRIDGE · JSON_MERGE_PATCH · JSON_PATCH · SSE ·
NDJSON · JSON_SEQUENCE · FUNCTIONAL_WEBFLUX · CBOR · XML · RATELIMIT_DRAFT_HEADERS      = 11

advanced/** 전체에서 프로덕션 @Configuration은 셋이고(MvcStreamingExecutorConfiguration · VirtualThreadMvcConfiguration · VirtualThreadSettings) 실제 @ConditionalOnProperty 접두사는 둘이다:

backend.web.advanced.mvc-virtual-threads   -> MVC_VIRTUAL_THREADS
backend.web.advanced.ndjson                -> NDJSON

나머지 아홉(WEBFLUX_BLOCKING_BRIDGE · JSON_MERGE_PATCH · JSON_PATCH · SSE · JSON_SEQUENCE · FUNCTIONAL_WEBFLUX · CBOR · XML · RATELIMIT_DRAFT_HEADERS)에는 프로퍼티도, @Configuration도, 빈도 없다. §36.1.

35.2 (8.1) 도달성 — 플래그 값 자체를 읽는 코드
$ grep -rn 'WebAdvancedFeatureFlags\|WebAdvancedFeature\b' src app-bootstrap/src | grep -v advanced/WebAdvancedFeature
test/.../advanced/release/WebAdvancedRollbackIT.java:5, 88, 91, 93, 148
test/.../advanced/release/WebAdvancedReleaseTest.java:6, 7, 21, 23, 34

"운영자가 출력할 수 있는 하나의 값"으로 설계된 WebAdvancedFeatureFlags를 읽는 프로덕션 코드가 없다. 실제 활성화는 @ConditionalOnProperty 두 개로 이루어지고, 그 둘은 이 값과 무관하게 동작한다.

35.3 (8.2) 조건 형제 비교 — 같은 스위치의 세 가지 철자
출처 문자열 상태
WebAdvancedFeature.propertyName() (:60) backend.web.advanced.mvc-virtual-threads.enabled (계산됨) 테스트만 호출
VirtualThreadMvcConfiguration:33 backend.web.advanced.mvc-virtual-threads 실제 게이트
VirtualThreadProfile.propertyName() (:75) backend.web.advanced.virtual-threads.enabled 호출자 0, 그리고 mvc- 접두사가 없어 위 둘과 불일치

§36.2.

35.4 (8.3) 중복 메커니즘 — 하나의 스위치가 두 능력을 켠다
// advanced/mvc/MvcStreamingExecutorConfiguration.java:14-15, 36-39
/** Wires servlet-side record streaming: NDJSON and RFC 7464 JSON text sequences. */
@ConditionalOnProperty(prefix = "backend.web.advanced.ndjson", name = "enabled", havingValue = "true")

NDJSONJSON_SEQUENCE는 enum에서 서로 다른 상수이고 각자 프로퍼티 이름을 갖는데, 실제로는 ndjson 스위치 하나가 둘을 함께 켠다. WebAdvancedFeature의 javadoc이 금지한 형태다 — "A single switch would make those one decision."

36. Sub-scope 09 findings

36.1 P2 — 선언된 Advanced 능력 11개 중 9개는 켜는 방법이 없다

§35.1. webAdvancedTest 레인이 이 능력들을 전부 돌리고(web-advanced-nightly.yml:46 · web-advanced-release.yml:53), WebAdvancedRollbackIT가 능력마다 플래그를 켰다 껐다 하며 롤백을 검증한다. 그러나 그 검증은 WebAdvancedFeatureFlags.of(feature)라는 테스트 전용 값에 대한 것이고, 배포가 실제로 조작할 수 있는 스위치는 두 개뿐이다.

실패 시나리오 — 팀이 JSON Patch를 쓰기로 한다. WebAdvancedFeature.JSON_PATCH.propertyName()이 알려 주는 backend.web.advanced.json-patch.enabled=true를 설정한다. 아무 일도 일어나지 않는다 — 그 프로퍼티를 읽는 조건이 없고, application/json-patch+json을 처리할 메시지 컨버터나 컨트롤러 조언을 등록하는 코드도 없다. advanced/patch의 13개 파일 886 LOC(포인터 단위 권한 검사 포함)은 여전히 도달 불가다. 오류도 경고도 없다.

같은 것이 SSE(advanced/webflux/WebFluxSseAdapter 117줄), 함수형 라우팅(WebFunctionalHandlerAdapter 102줄), 블로킹 브리지, CBOR·XML 표현(SS7 §27.3), 그리고 draft rate-limit 헤더(SS4의 advanced/ratelimit 3파일)에 적용된다.

이것이 SS4·SS5의 P1과 다른 점 — 저기서는 플랫폼이 설치해야 할 것을 설치하지 않았다(기본 켜짐이어야 할 예산·멱등성). 여기서는 능력이 옵트인인 것이 맞고, 옵트인할 수단이 없다. 그래서 P1이 아니라 P2다.

권고WebAdvancedFeature가 이미 프로퍼티 이름을 계산한다. 능력마다 그 이름으로 게이트된 @Configuration을 두거나, 아직 배선할 수 없는 상수를 enum에서 빼서 "선언된 능력"과 "켤 수 있는 능력"이 같아지게 한다. WebAdvancedReleaseTest:48JSON_MERGE_PATCH.propertyName()을 단언하고 있으므로, 테스트는 이미 그 이름이 의미를 갖는다고 전제한다.

36.2 P3 — VirtualThreadProfile.propertyName()이 아무것도 게이트하지 않는 이름을 반환한다

§35.3. VirtualThreadProfile:75"backend.web.advanced.virtual-threads.enabled"를 하드코딩한다. 실제 게이트는 mvc-virtual-threads이고, 같은 능력에 대해 enum이 계산하는 이름도 mvc-virtual-threads다. 이 메서드는 호출자가 0이므로 지금 오작동을 만들지는 않지만, "The property that turns this on"이라는 javadoc과 함께 잘못된 이름을 발행한다 — 운영자가 이 문서를 보고 설정하면 켜지지 않는다.

36.3 P3/기록 — ndjson 스위치가 JSON_SEQUENCE도 함께 켠다

§35.4. enum이 두 능력을 분리하고 그 분리의 이유를 명시하는데, 실제 게이트는 하나다. MvcStreamingExecutorConfiguration의 javadoc이 "NDJSON and RFC 7464 JSON text sequences"를 함께 배선한다고 정직하게 적고 있으므로 은폐는 아니고, 카탈로그와 게이트의 입도가 다르다는 기록이다.

37. Sub-scope 09 완료 조건

  • denominator 65 / 65 FULL_READ
  • §8.1~§8.4 수행 — 카운트 드리프트에서 P2 1건, 이름 불일치에서 P3 1건, 기록 1건
  • 소스 미변경

Sub-scope 10 — fileserver/** (73 files, main 51 + test 22)

내부 상태: COMPLETE — 73 / 73 FULL_READ · 근거 evidence/raw/199-inbound-web-fileserver-probes.txt

38. 무엇을 하는 코드인가

이 leaf에서 실제로 조립되는 유일한 큰 하위 트리다. app-bootstrap이 이 leaf에서 import하는 19개 타입 중 18개가 여기 있고(§3.3), FileserverPlatformAutoConfigurationFileserverStartupConfiguration이 그것들을 빈으로 만든다.

일곱 개 @RestController(업로드 · 다운로드 · TUS · draft-12 · 라이프사이클 · 관리 · 문제 핸들러)가 전부 @ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")로 게이트되고, MvcTransferExecutorConfigurationFileserverReactiveConfiguration이 실행기를 배선한다.

보안 코드의 품질이 이 모듈 최고 수준이다.

DefaultNginxInternalUriMapperX-Accel-Redirect 위임 — 애플리케이션이 내는 헤더가 프록시에서 파일시스템 조회가 되는 경로 — 를 세 겹으로 막는다:

private static final Pattern SHARDED_KEY = Pattern.compile("[a-z0-9]{2}/[a-z0-9]{2}/[a-z0-9_-]{12,190}");

public String mapUnchecked(String rawKey) {
  if (rawKey == null || !SHARDED_KEY.matcher(rawKey).matches()) { throw InvalidPathException...; }
  String uri = properties.internalPrefix() + rawKey + properties.objectSuffix();
  if (uri.contains("..") || uri.contains("//") || uri.indexOf('\\') >= 0) { throw ...; }
  return uri;
}

앵커된 정규식 + 구성 후 재검사이고, 그 이유가 적혀 있다 — "a header that reaches Nginx as an internal redirect is effectively a filesystem lookup: a traversal that survived to this point would be served, not rejected."

그리고 시작 시 검증이 있다. attestMapping()이 대표 키를 실제 접두사·접미사로 왕복시켜 매핑 형태를 확인하고, 그 이유가 이 저장소에서 가장 정확한 실패 서술 중 하나다:

"The failure this catches is silent by nature: a prefix the proxy does not resolve makes the server answer 200 with an empty body, so the client believes it received the file. Better to refuse to start."

그리고 그것은 실제로 호출된다app-bootstrap/.../FileserverStartupConfiguration.java:87. 이 모듈에서 "장치가 있고 회로가 닫힌" 사례다.

FileserverRequestContextFactory도 마찬가지다. SecurityContextHolder를 읽어 프레임워크 자유 FileAccessSubject를 만들고, 미인증은 null이 아니라 익명 주체가 되며, 주입된 접근 정책이 그것을 허용할지 결정한다. 이것이 SS3의 WebSecurityContextBridge가 하려던 일이고, 이쪽은 배선되어 있다.

TusChecksumVerifier는 클라이언트가 보낸 체크섬을 서버가 계산한 다이제스트와 비교만 하고 대체하지 않는다 — "A checksum the server did not compute proves nothing, and accepting one would let a client declare corrupt bytes to be intact."

ZeroCopyEligibility는 TLS 연결에서 sendfile을 거부한다 — "TLS has to see the plaintext, so a sendfile would bypass the very layer that must transform it."

39. Negative-space probes — sub-scope 10

39.1 (8.1) 도달성 — 시작 검증과 조립
$ grep -rn 'attestMapping' --include=*.java adapter app-bootstrap sample-portfolio
main/.../nginx/DefaultNginxInternalUriMapper.java:41   (구현)
main/.../nginx/NginxInternalUriMapper.java:32          (선언)
BOOT:autoconfigure/fileserver/FileserverStartupConfiguration.java:87   uriMapper.attestMapping()

FileserverPlatformAutoConfigurationDefaultNginxInternalUriMapper(:215-216) · NginxDownloadStrategy(:221-223) · FileserverRequestContextFactory(:159-161)를 만든다. 회로 닫힘.

39.2 (8.2) 조건 형제 비교 — 두 전송의 fileserver
조건 파일
서블릿 @ConditionalOnProperty(app.fileserver-platform.enabled=true) + @RestController controller 2 · tus 1 · draft12 1 · lifecycle 1 · admin 1 · problem 1
리액티브 @ConditionalOnWebApplication(type = REACTIVE) + 같은 프로퍼티 reactive 10

리액티브 쪽 조건이 §40.1의 대상이다.

39.3 (8.3) 중복 메커니즘 — 없음

MIME 조립·문제 문서·요청 컨텍스트가 각각 한 벌이다. FileserverProblemFactory/FileserverExceptionHandler는 SS2의 두 계약과 별개인 세 번째 에러 형식(FileserverProblem)이지만, @ConditionalOnProperty로 이 능력에만 붙고 경로가 겹치지 않는다. 능력별 문제 문서로 정당하다.

39.4 (8.4) 문서/구현 드리프트 — 리액티브 활성화 조건

build.gradle이 리액티브 핸들러의 활성화 조건을 이렇게 적는다:

"DispatcherServlet stays present, so Spring Boot's WebApplicationType deduction keeps resolving SERVLET; the reactive handlers are wired only when the fileserver reactive profile is selected."

실제 조건은 프로파일이 아니다:

// fileserver/reactive/FileserverReactiveConfiguration.java:38-39
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")

§40.1.

40. Sub-scope 10 findings

40.1 P1 — 이 leaf의 리액티브 절반 29개 파일은 어떤 출하 배포에서도 활성화될 수 없다

@ConditionalOnWebApplication(type = REACTIVE)는 Spring Boot의 WebApplicationTypeREACTIVE일 때만 참이다. deduceFromClasspath()DispatcherServletServletContainerInitializer가 있으면 WebFlux가 함께 있어도 SERVLET을 고른다.

이 저장소의 클래스패스는 SERVLET을 고정한다:

  • 이 leaf의 build.gradlespring-boot-starter-web(Tomcat + DispatcherServlet)을 implementation으로 선언하고, WebFlux는 spring-webflux + reactor-core만 — 즉 서버 없는 프레임워크만 — 가져온다. 그 선택의 근거도 적혀 있다("which would put a second embedded server (reactor-netty) on the runtime classpath").
  • app-bootstrap/gradle.lockfilespring-boot-starter-tomcat/spring-webmvc 항목이 있다.
  • sample-portfolio/build.gradle:45spring-boot-starter-web을 선언한다.
  • main 소스 어디에도 setWebApplicationType(REACTIVE)가 없다(테스트 하네스 3곳에만 WebApplicationType 참조가 있고 전부 NONE/SERVLET).

따라서 WebApplicationType은 항상 SERVLET이고, 이 leaf의 모든 @ConditionalOnWebApplication(REACTIVE)는 영구히 거짓이다.

꺼진 채로 남는 것 (main 29 파일):

패키지 파일 잃는 것
webflux/autoconfigure 2 리액티브 플랫폼 빈 11개
webflux/context 2 WebFluxRequestContextFilter — 이 leaf의 유일한 프로덕션 WebRequestContext 생산자
webflux/error · budget · guard · idempotency · operation · throttle 8 리액티브 문제 문서 · 예산 · 블로킹 가드 · 멱등성 · 작업 조회 · 스로틀
fileserver/reactive 10 리액티브 업로드/다운로드 핸들러, IO 스케줄러, 라우터
advanced/webflux 7 SSE 어댑터 · 하트비트 · 스트림 승인 · 블로킹 브리지

SS3 §12.1이 이것으로 완결된다. 거기서 "서블릿에는 WebRequestContext 생산자가 없고 리액티브에는 익명 고정 생산자가 있다"고 기록했다. 이 발견을 합치면 출하되는 어떤 배포에도 생산자가 없다 — 리액티브 생산자가 등록되는 조건이 성립하지 않기 때문이다. WebMvcRequestContextArgumentResolver는 자동설정이 등록하고 항상 던진다.

build.gradle의 서술이 이 상태를 반쯤 알고 있다. "the reactive handlers are wired only when the fileserver reactive profile is selected"라고 적었는데, Spring 프로파일은 WebApplicationType 추론을 바꿀 수 없다. 그것을 바꾸는 것은 클래스패스이거나 SpringApplication.setWebApplicationType(...) 뿐이고, 둘 다 이 저장소에 없다.

그 클래스패스를 만드는 곳은 하나뿐이다 — 테스트 레인. 같은 파일이 그 사실을 정확히 적는다:

webfluxContractTest { … inherits() } — "Inherits nothing. The default is to extend testImplementation, which extends the leaf's own implementation and therefore carries spring-boot-starter-web — and with Tomcat on the classpath Boot deduces a servlet application, starts a servlet container, and the reactive gate certifies the servlet stack while reporting itself green."

이 주석은 레인에 대해 완전히 옳고, 같은 사실이 프로덕션에 대해서도 성립한다는 점만 적히지 않았다. webfluxContractTest 소스셋은 리액티브 코드가 실행될 수 있는 유일한 클래스패스를 만들기 위해 존재하고, 그 클래스패스는 출하 아티팩트에 없다.

실패 시나리오 — 팀이 app.fileserver-platform.enabled=true로 fileserver를 켜고 리액티브 전송을 쓰기로 한다. 문서(build.gradle 주석)가 말하는 "fileserver reactive profile"을 찾지만 그런 프로파일은 없다. 프로퍼티를 무엇으로 설정해도 FileserverReactiveConfiguration은 활성화되지 않고, 서블릿 컨트롤러가 계속 응답한다. 오류도 경고도 없고 webFluxContractTest 레인은 초록색이다.

권고 — 두 선택지가 있고 어느 쪽이든 문서가 따라가야 한다. (1) 리액티브를 실제 선택지로 만든다: 별도 배포 아티팩트가 spring-boot-starter-webflux를 쓰고 spring-boot-starter-web을 제외하도록 조립 경로를 만든다(레인이 이미 그 형태를 갖고 있다). (2) 리액티브를 지원하지 않는다고 선언하고 29개 파일과 레인을 제거한다. 지금은 셋 다 아니다 — 코드가 있고, 레인이 초록이고, 배포는 그것을 켤 수 없다.

40.2 P3/기록 — 리액티브 활성화 조건에 대한 build.gradle 서술이 코드와 다르다

§39.4. "the reactive handlers are wired only when the fileserver reactive profile is selected" — 실제 조건은 @ConditionalOnWebApplication(REACTIVE)이고 프로파일과 무관하다. §40.1의 일부이지만 문서 수정만으로 닫히지 않는다는 점에서 별도로 기록한다.

41. Sub-scope 10 완료 조건

  • denominator 73 / 73 FULL_READ
  • §8.1~§8.4 수행 — 활성화 조건에서 P1 1건, 문서 드리프트 1건
  • 서블릿 fileserver 경로는 이 모듈에서 유일하게 완전히 조립된 하위 트리로 확인됨(결함 0)
  • 소스 미변경

Sub-scope 11 — notification/platform/** + admin/** (26 files, main 22 + test 4)

내부 상태: COMPLETE — 26 / 26 FULL_READ · 근거 evidence/raw/200-inbound-web-notification-admin-probes.txt

42. 무엇을 하는 코드인가

notification/platform (16) — 알림 제출/템플릿 HTTP 표면과 provider 콜백 수신. 두 전송 모두 지원한다(callback MVC 5 + callback/reactive 4). ca-skeleton.notification.platform...callbacks 두 프로퍼티로 게이트되고, 자기 의존을 스스로 공급한다:

// CallbackRequestConfiguration.java  (@ConditionalOnProperty(...callbacks.enabled=true))
@Bean @ConditionalOnMissingBean(ExternalRequestUrlResolver.class)
public ExternalRequestUrlResolver externalRequestUrlResolver(
    @Value("${ca-skeleton.notification.platform.callbacks.trusted-proxies:}") Set<String> trustedProxies) { ... }

@Bean @ConditionalOnMissingBean(CallbackRequestFactory.class)
public CallbackRequestFactory callbackRequestFactory(ExternalRequestUrlResolver urlResolver, Clock clock) { ... }

javadoc이 이 형태를 택한 이유를 자기고발로 적는다 — "...reference got a deployment that would not boot." SS4 §16.3과 SS5 §20.2에서 확인한 "게이트를 켜면 미충족 의존성으로 부팅 실패"가 여기서는 이미 고쳐져 있다.

신뢰 프록시 결정이 여기서는 배선되어 있다. 기본값이 빈 집합이고 그 이유가 명확하다:

"The trusted-proxy set is empty by default, and that default is the safe one rather than the convenient one: with no entry, forwarded headers are never honoured and the resolver uses what the container observed. Honouring them unconditionally would let any caller choose the URL that gets signature-verified, which defeats the signature."

SS8 §32.2에서 확인한 proxy 패키지(421 LOC, 미배선)와 정확히 대조된다 — 같은 판단이 필요한 두 곳 중 콜백 서명 검증 쪽은 연결했고 플랫폼 전역 쪽은 연결하지 않았다.

admin (6) — 런타임 라우트 목록(WebRouteInventory · WebRouteContract · SpringMvcRouteInventoryCollector)과 플랫폼 스냅샷·시작 검증(WebPlatformSnapshot · WebPlatformStartupValidator).

43. Negative-space probes — sub-scope 11

43.1 (8.1) 도달성 — admin 여섯 파일
SpringMvcRouteInventoryCollector   저장소 전체 참조: 자기 파일 2줄뿐 (테스트도 0)
WebPlatformStartupValidator        test 5, main/boot 0
WebRouteInventory                  main 2, test 7
WebPlatformSnapshot                main 1, test 3

§44.1·§44.2.

43.2 (8.2) 조건 형제 비교 — 시작 검증 두 개의 운명
장치 무엇을 확인하는가 시작 시 호출
NginxInternalUriMapper.attestMapping() (SS10) 내부 URI 매핑 형태 FileserverStartupConfiguration:87
WebPlatformStartupValidator 필수 플랫폼 구성 요소의 존재 아니오 — 테스트에서만 생성

같은 종류의 장치가 한쪽은 회로가 닫혔고 한쪽은 열려 있다.

43.3 (8.3) 중복 메커니즘 — 신뢰 프록시 판정

ExternalRequestUrlResolver(71줄, 배선됨, 기본 빈 신뢰 집합)와 proxy/TrustedProxyPolicy(161줄, 미배선). 전자는 콜백 서명 검증용 URL에만 적용되고 후자는 플랫폼 전역용이다. 범위가 다르므로 중복은 아니지만, 같은 문제에 대한 두 답 중 하나만 연결돼 있다는 사실은 §32.2와 함께 읽힌다.

43.4 (8.4) 게이트 프로퍼티가 존재하는가
$ grep -rh 'prefix = "' notification/     -> ca-skeleton.notification.platform
                                          -> ca-skeleton.notification.platform.callbacks
$ grep -rn 'web-platform.notification|notification.*web-platform' --include=*.yml .   -> 없음

어떤 application.yml에도 이 프로퍼티가 없다 — 기본 꺼짐이고, 켜는 방법은 명확하며(§42), 켜면 의존이 갖춰진다. SS4·SS5·SS9와 달리 여기는 닫힌 옵트인이다.

44. Sub-scope 11 findings

44.1 P3 — SpringMvcRouteInventoryCollector 138줄에 참조가 하나도 없다

저장소 전체에서 이 타입 이름이 등장하는 곳은 자기 파일의 클래스 선언과 생성자 두 줄뿐이다. 테스트도 없다.

이 leaf에서 확인한 미조립 사례 대부분(§16.1 · §20.1 · §32.2)은 최소한 테스트나 픽스처가 생성했다 — "검증되었으나 배선되지 않은" 형태였다. 이것은 그것보다 한 단계 더 나아간, 작성되었고 어디서도 인스턴스화되지 않은 138줄이다. WebRouteInventory(104줄)가 requireRegisteredOperations(WebOperationCatalog)로 릴리스 게이트 역할을 하도록 설계돼 있고(SS5 §19.3), 그 목록을 실제 Spring MVC 라우트에서 수집하는 것이 이 클래스의 역할인데, 수집이 일어나지 않으므로 목록도 비교도 없다.

44.2 P3 — WebPlatformStartupValidator가 시작 시 실행되지 않는다

§43.1·§43.2. 이름이 약속하는 시점에 아무도 부르지 않는다. 같은 leaf의 fileserver 하위 트리는 같은 종류의 시작 검증을 app-bootstrap의 @Bean으로 연결했고(SS10 §39.1), 그 근거를 "Better to refuse to start"로 적었다. 플랫폼 쪽 검증기에는 그 연결이 없다.

이 두 발견은 admin 패키지 6개 파일 중 4개가 운영 가시성 장치이면서 운영 시점에 도달하지 않는다는 하나의 사실이다.

44.3 — notification/platform 16개 파일: 결함 없음

게이트, 의존 공급, 신뢰 프록시 기본값, 두 전송 대칭이 모두 갖춰져 있다. 리액티브 절반(callback/reactive 4파일)은 §40.1의 @ConditionalOnWebApplication(REACTIVE) 문제를 공유하지만, 그것은 이 sub-scope의 결함이 아니라 모듈 전체의 조건이다.

45. Sub-scope 11 완료 조건

  • denominator 26 / 26 FULL_READ
  • §8.1~§8.4 수행 — 도달성에서 P3 2건
  • 소스 미변경

Sub-scope 12 — testkit + webfluxContractTest + jettyCompatTest + nginxProxyTest (94 files)

내부 상태: COMPLETE — 94 / 94 FULL_READ · 근거 evidence/raw/201-inbound-web-testkit-probes.txt

46. 무엇을 하는 코드인가

네 개의 별도 소스셋. testkit(54)이 계약과 하네스를 담고, 나머지 셋(16 + 9 + 5)이 그것을 서로 다른 런타임에서 재실행한다.

계약 클래스가 소스셋 분리로 공유된다. WebBudgetContract · WebThrottleHttpContract · OperationHttpContract · WebPipelineOrderContract · WebLoadAndShutdownContract · IdempotencyResponseLossContract · WebPlatformContractSuite — 각각이 추상 계약이고, Tomcat(test) · Jetty(jettyCompatTest) · Reactor Netty(webfluxContractTest) 세 레인이 구현한다. 그리고 webCrossStackParityTest가 세 레인이 남긴 기록을 비교한다(WebPlatformContractRecording · WireOutcome · WireProbe).

이것은 notification 모듈의 ProviderAdapterContract(상속 3/8, §29.2)보다 강한 형태다 — 상속에 의존하지 않고 소스셋과 태스크 의존이 강제한다.

testkit/arch 4개는 회로가 닫혀 있다. WebArchitectureRules가 7개 ArchUnit 규칙을 발행하고, app-bootstrapWebProductionArchitectureTest:47WebArchitectureRules.all()을 프로덕션 트리에 적용한다. build.gradle이 그 배선의 이유를 적는다 — "A rule pack that only its own fixture tests import is verified as library code and applied to nothing — the shape the JPA testkit had to be corrected out of." 그리고 WebArchitectureRulesTest:86-88이 규칙 개수(7 / 6 / 1)를 고정해 규칙이 조용히 사라지는 것을 막는다.

ProviderFaultHarness 계열이 실제 소켓을 쓴다. HttpResponseLossFixture · ResponseLossFixture · WebFaultInjector가 본문 커밋 후 연결 절단을 재현한다 — 목으로는 만들 수 없는 조건이다.

nginxProxyTest(5) 는 Testcontainers로 진짜 Nginx를 띄우고 프록시·접두사·스푸핑 계약을 검증한다(§32.2).

47. Negative-space probes — sub-scope 12

47.1 (8.1) 도달성 — 픽스처 애플리케이션이 조립하는 것

각 픽스처 애플리케이션이 new로 만드는 플랫폼 타입:

BudgetFixtureApplication           BudgetProblemMapper  WebMvcBudgetFilter
ReactiveBudgetFixtureApplication   BudgetProblemMapper  WebFluxBudgetFilter
ThrottleFixtureApplication         ThrottleProblemWriter  WebMvcThrottleFilter  CountingRateLimiter
ReactiveThrottleFixtureApplication ThrottleProblemWriter  WebFluxThrottleFilter CountingRateLimiter
ContractFixtureApplication         WebProblemFactory
ReactiveContractFixtureApplication WebProblemFactory  WebProblemSanitizer
ReactiveSseFixtureApplication      WebFluxSseAdapter  WebStreamPolicy  StreamId  StreamSequence
PipelineOrderFixtureApplication    WebPipelineRecorder + 익명 Filter/HandlerInterceptor

이 목록의 왼쪽 열이 §48.1의 내용이다.

47.2 (8.2) 조건 형제 비교 — 두 개의 계약 강제 형태
notification ProviderAdapterContract web WebBudgetContract 외 6종
강제 수단 상속(강제 없음) 소스셋 + dependsOn 태스크 그래프
실제 적용 8종 중 3종 세 런타임 전부
빠진 것을 잡는 장치 없음 webCrossStackParityTest가 세 기록을 비교하고, 하나라도 없으면 실패

web 쪽이 구조적으로 우월하다. build.gradle이 그 이유를 적는다 — "a parity check that compares whatever happens to be present would report agreement across a matrix with a hole in it."

47.3 (8.3) 중복 메커니즘 — 없음

WebArchitectureRules가 유일한 규칙 팩이고 소비자가 둘(자기 테스트 + app-bootstrap 프로덕션 적용)이다. 계약 클래스도 각 능력당 하나다.

47.4 (8.4) 카운트 고정

WebArchitectureRulesTest:86-88all() 7 · webScopedRules() 6 · crossLeafRules() 1을 단언한다. 규칙이 추가되거나 사라지면 테스트가 먼저 깨진다. 이 leaf에서 발견한 카운트 드리프트(§35.1의 능력 11 대 게이트 2)와 반대되는, 고정이 작동하는 사례다.

48. Sub-scope 12 findings

48.1 P1 — 크로스 스택 게이트가 검증하는 조립은 픽스처의 조립이고, 플랫폼의 조립이 아니다

이 leaf는 이 저장소에서 가장 정교한 검증 장치를 갖고 있다 — 여섯 소스셋, 다섯 커스텀 레인, 세 런타임 패리티 비교, 실제 Nginx 컨테이너, 실제 소켓 고장 주입. 그리고 §47.1이 보여주듯 그 장치가 세우는 애플리케이션은 픽스처 애플리케이션이다.

BudgetFixtureApplicationFilterRegistrationBean<WebMvcBudgetFilter>를 손수 등록한다. JettyWebBudgetIT · ReactiveWebBudgetIT가 그 애플리케이션을 띄워 예산 계약을 세 런타임에서 증명한다. 증명되는 명제는 "이 필터가 등록되면 예산이 지켜진다"이고, "플랫폼이 이 필터를 등록한다"는 명제는 어떤 레인도 세우지 않는다 — 그리고 §16.1이 확인했듯 플랫폼은 등록하지 않는다.

같은 구조가 스로틀(§16.1) · 멱등성(§20.1) · SSE(§36.1) · 요청 컨텍스트(§12.1)에 반복된다.

빠진 검증은 하나다 — 두 자동설정(WebMvcPlatformAutoConfiguration · WebFluxPlatformAutoConfiguration)이 세운 컨텍스트에 무엇이 있는지 확인하는 테스트. WebMvcPlatformAutoConfigurationTest(112줄)와 WebFluxPlatformAutoConfigurationTest(112줄)가 존재하지만 자동설정이 선언한 빈들을 확인할 뿐, 필터 체인에 예산·스로틀·멱등성이 있는지는 묻지 않는다 — 자동설정이 그것들을 선언하지 않으므로 확인할 것도 없다.

이 sub-scope에 P1을 두는 이유 — 결함은 픽스처에 있지 않다. 픽스처는 정확하고 계약은 잘 쓰였다. 결함은 검증 전략의 경계에 있다: 이 leaf는 "능력이 올바른가"를 다섯 레인으로 묻고 "플랫폼이 능력을 설치하는가"를 묻는 레인을 하나도 갖지 않는다. 그 공백이 §12.1 · §16.1 · §20.1 · §36.1 · §40.1 다섯 개의 P1/P2가 초록색 스위트 아래에서 성립할 수 있게 한 단일 원인이다.

권고 — 픽스처를 하나 더 만드는 것이 아니라, 아무것도 등록하지 않는 픽스처를 하나 만든다: @SpringBootConfiguration + @EnableAutoConfiguration만 있고 @Bean이 없는 애플리케이션을 띄워 필터 체인·컨트롤러 조언·인터셉터 목록을 스냅샷으로 고정한다. 그 스냅샷이 §16.1 · §20.1을 즉시 드러내고, 이후 회귀도 막는다. WebPlatformContractRecording이 이미 기록·비교 형태를 갖고 있으므로 형식은 있다.

48.2 — testkit·레인 자체의 결함: 없음

계약 강제(§47.2), 규칙 팩 배선(§46), 카운트 고정(§47.4), 실제 소켓·실제 프록시 사용이 모두 갖춰져 있다. nginxProxyTest가 Docker 부재 시 조용히 통과하지 않고 실패한다는 점(build.gradle: "A lane that quietly passes when the container runtime is missing is a lane that has been certifying nothing since whenever Docker last broke")까지 포함해, 이 소스셋들은 이 저장소가 검증에 대해 아는 것을 가장 잘 보여준다.

49. Sub-scope 12 완료 조건

  • denominator 94 / 94 FULL_READ
  • §8.1~§8.4 수행 — 검증 경계에서 P1 1건
  • 소스 미변경

50. 모듈 종합 — adapter-inbound-web

50.1 커버리지 원장 정산

# sub-scope main test 기타 상태
1 governance + config·core·contract·moduleboundary·*/autoconfigure 30 17 4 51 COMPLETE
2 error + validation + envelope 23 10 33 COMPLETE
3 auth + authz + security 27 17 44 COMPLETE
4 ratelimit + admission + budget + */throttle 41 9 50 COMPLETE
5 idempotency + operation + operationasync + evidence 40 10 50 COMPLETE
6 pagination + cursor + conditional + cache + versioning 42 12 54 COMPLETE
7 http + json + advanced/codec + openapi 34 11 45 COMPLETE
8 observability + proxy + filter + mvc/*·webflux/* 잔여 38 15 53 COMPLETE
9 advanced/** 52 13 65 COMPLETE
10 fileserver/** 51 22 73 COMPLETE
11 notification/platform/** + admin/** 22 4 26 COMPLETE
12 testkit + 대체 소스셋 3종 0 10 84 94 COMPLETE
합계 400 150 88 638 12 / 12

FULL_READ 638 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0. 각 sub-scope의 분모는 evidence 189201OWNED FILES 블록과 file_count 라인이 확인한다. 분할은 188-...의 패키지 트리에서 기계적으로 계산했고 중복 0 · 미할당 0이다.

50.2 발견 종합 — P1 6건 · P2 8건 · P3 9건 · 기록 9건

심각도 § 발견
P1 8.1 RFC 9457 계약 23개 파일이 출하 애플리케이션에 등록되지 않는다 — 컴포지션 루트가 mvc.error·webflux.error·mvc.budget·mvc.operation·webflux.operation 다섯 패키지를 컴포넌트 스캔에서 제외하고("Ownership by auto-configuration is what ties a control's presence to its dependency's"), 그것을 넘겨받는 자동설정이 없다. README의 D5(ProblemDetail 거부)만 출하된다
P1 12.1 플랫폼 요청 컨텍스트가 서블릿에는 생산자가 없고(store() 호출자 0 · 리졸버는 항상 throw), 리액티브에는 ActorContext.anonymous()로 고정
P1 16.1 용량 보호 계층 41개 main 파일(예산 · 승인 · 스로틀)이 픽스처에서만 등록된다 — 프로덕션에 요청 본문 상한도 데드라인도 동시성 상한도 없다
P1 20.1 멱등 실행 계층 38개 파일이 픽스처에서만 조립된다 — Idempotency-Key가 읽히지 않고 거부되지도 않는다
P1 40.1 @ConditionalOnWebApplication(REACTIVE)가 걸린 29개 main 파일이 어떤 출하 배포에서도 활성화될 수 없다(클래스패스가 SERVLET을 고정). §12.1과 합치면 어떤 배포에도 WebRequestContext 생산자가 없다
P1 48.1 다섯 레인·세 런타임 패리티가 검증하는 것은 픽스처의 조립이고, "플랫폼이 능력을 설치하는가"를 묻는 레인이 없다 — 위 다섯 발견의 단일 원인
P2 12.2 프레임워크 자유 신원 모델 11파일과 교차 테넌트 가드 rejectTenantInput이 프로덕션에서 참조 0
P2 16.2 리액티브 전송에 속도 제한 경로가 하나도 없다
P2 24.1 배선된 CacheControlFilterno-store가 배선된 조건부 읽기(ETag/304)를 무력화하고, 조정용 cache 패키지 310 LOC은 참조 0
P2 28.1 maxArrayElements가 선언만 되고 강제되지 않으며 바이트 예산 백스톱(§16.1)도 없다
P2 32.1 요청 식별자를 클라이언트가 고를 수 없다는 정책이, 뒤에 도는 다른 배선 필터에 의해 뒤집힌다
P2 32.2 forwarded 헤더 신뢰 판정이 Nginx 설정에만 있고 Java 정책 421 LOC은 미배선
P2 36.1 선언된 Advanced 능력 11개 중 9개는 켜는 방법이 없다
P3 ×9 8.2 · 20.3 · 24.3 · 25.3 · 25.4 · 36.2 · 44.1 · 44.2 · 28.3 외 죽은 메서드 · 구분자 기반 지문 · 미강제 한도 · 이름 불일치 · 참조 0인 138줄 · 실행되지 않는 시작 검증 등

50.3 이 모듈의 성격 — 하나의 원인, 여섯 개의 결과

이 leaf는 코드 품질이 저장소 최고 수준이다. 자기고발 주석의 밀도는 notification보다 높고, 모듈 경계는 스캐너로 강제되며(§3.4), 검증 장치는 여섯 소스셋·다섯 레인·세 런타임 패리티·실제 Nginx 컨테이너·실제 소켓 고장 주입까지 갖췄다.

그런데 main 397개 파일 중 프로덕션 컨텍스트에 실제로 들어가는 것은 소수다. 두 자동설정이 23개 빈을 등록하고, 컴포넌트 스캔이 @RestController 18 · @RestControllerAdvice 6 · @Component 7 · @Configuration 23을 잡고, app-bootstrap이 19개 타입을 명시적으로 배선한다 — 그리고 그 19개 중 18개가 fileserver다.

원인은 하나다. 이 leaf는 "능력을 구현하는 코드"와 "능력을 검증하는 픽스처"를 둘 다 갖고, "능력을 조립하는 자동설정"을 갖지 않는다.

fileserver가 반례이자 증거다. 그것만 app-bootstrap에 전용 자동설정(FileserverPlatformAutoConfiguration · FileserverStartupConfiguration)을 갖고, 그래서 그것만 완전히 조립된다 — 시작 시 검증(attestMapping())까지 호출되고(§39.1), 자기 보안 브리지를 배선했으며(§38), 신뢰 프록시 결정을 명시적으로 내린다. 같은 판단이 필요한 플랫폼 쪽(WebSecurityContextBridge · TrustedProxyPolicy · WebPlatformStartupValidator)은 전부 미배선이다.

notification/platform이 두 번째 반례다 — 자기 @Configuration에서 의존을 @ConditionalOnMissingBean으로 공급하고, javadoc이 그렇게 하는 이유를 "...reference got a deployment that would not boot"로 적는다. 이 leaf는 그 실패를 한 번 겪고 한 곳에서 고쳤다. budgets(§16.3)와 durable-operations(§20.2)에는 같은 수정이 적용되지 않았다.

검증 장치가 이 상태를 가리는 방식이 이 모듈의 핵심 관찰이다. 레인은 다음을 증명한다:

"WebMvcBudgetFilter가 등록되면 예산이 Tomcat·Jetty·Reactor Netty에서 동일하게 지켜진다."

그리고 다음을 묻지 않는다:

"플랫폼이 WebMvcBudgetFilter를 등록하는가."

첫 문장이 참이고 둘째 질문의 답이 "아니오"인 상태는, 다섯 레인이 전부 초록인 채로 성립한다. §48.1의 권고 — 빈을 하나도 선언하지 않는 픽스처로 필터 체인 스냅샷을 고정하는 것 — 하나가 §12.1 · §16.1 · §20.1 · §36.1을 동시에 드러낸다.

50.4 다른 모듈과의 대조

형태 notification (모듈 13) inbound-web (모듈 14)
"장치는 있고 회로가 닫히지 않음" P2 7건 중 5건 P1 6건 중 5건
계약 강제 상속(8종 중 3종 적용) 소스셋 + 태스크 그래프(3 런타임 전부)
자기고발 주석 높음 더 높음
자기 패턴을 스스로 고친 사례 3건(배경 작업자 · webhook SSRF · VAPID 역사 키) 3건(Jackson 2→3 · @ConditionalOnBean 회피 · 프레임워크 탐지 정규식에 Jackson 2·3 병기)

두 모듈이 같은 병을 앓지만 규모가 다르다. notification에서는 개별 장치가 연결되지 않았고, web에서는 계층 전체가 연결되지 않았다. 그리고 web의 검증 장치가 더 정교하기 때문에, 그 공백이 더 잘 숨는다.

httpclient(모듈 11)의 P1 — TLS 실패가 CONNECT로 오분류 — 은 성격이 다르다. 그것은 로직 오류였고 테스트가 잡았다. 이 모듈의 P1 여섯 건은 전부 로직이 아니라 조립에 관한 것이고, 전부 테스트가 통과하는 상태에서 나왔다.

50.5 완료 게이트

  • denominator 638 / 638 FULL_READ, STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0
  • 12개 sub-scope 각각 §8.1~§8.4 negative-space probe 수행
  • evidence 188201 생성 (패키지 도달성 지도 192 포함), 각 파일에 OWNED FILES + revision + 실행 probe
  • 실행 검증: :adapter:inbound:web:test + :webSecurityBoundaryTest (§50.6)
  • 거짓 양성 후보 검증 후 기각: CorsSettingscontains("*")(→ 소비처가 setAllowedOrigins라 정확), SafeProblemDetailExtensions 호출자 0(→ WebProblem이 고정 컴포넌트 record라 우회 경로 없음), TusChecksumVerifier의 비상수시간 비교(→ 비교 대상이 클라이언트가 이미 아는 값), WebMvcBudgetExceptionHandler@ConditionalOnBean 의심(→ 실제로는 회피했고 주석이 그 이유를 적음), WebRequestId/WebTraceId의 문법 부재(→ 두 필터가 anchored 정규식으로 강제)
  • 소스 미변경 (git status --short = 0줄)
  • 분석 중 수정: §8.1은 처음에 "두 advice가 함께 등록되어 다섯 예외를 나눠 갖는다"로 기록했으나, 이어진 adapter-inbound-grpc 분석에서 CaSkeletonApplication의 컴포넌트 스캔 제외 정규식을 전수 확인하며 mvc.error·webflux.error·mvc.budget·mvc.operation·webflux.operation 다섯 패키지가 스캔에서 빠져 있고 그것을 등록하는 자동설정이 없다는 사실이 드러났다. §7.1·§7.2·§8.1·§50.2를 그 사실로 교체했다. 근거 evidence/raw/203-composition-root-scan-boundary.txt. §16.3·§20.2의 "게이트를 켜면 부팅 실패"는 정확히는 "스캔이 잡지 않아 조건 평가에도 이르지 못한다"이며 결과(능력이 켜지지 않음)는 같다.

50.6 실행 검증

$ ./gradlew :adapter:inbound:web:test :adapter:inbound:web:webSecurityBoundaryTest
> Task :adapter:inbound:web:test
> Task :adapter:inbound:web:webSecurityBoundaryTest
BUILD SUCCESSFUL in 24s
GRADLE_EXIT=0

test-results 집계: classes=176 tests=1221 failures=0 errors=0 **skipped=0**

두 레인 모두 통과. 이 모듈의 P1 6건과 P2 8건 중 테스트가 검출한 것은 하나도 없다 — §50.3이 서술한 이유 때문이다.

webCrossStackParityTest · webJettyCompatTest · webFluxContractTest · webNginxProxyTest · webAdvancedTest는 실행하지 않았다. 앞의 셋은 webCrossStackParityTest 하나로 묶여 있으나 Jetty·Reactor Netty 두 임베디드 서버를 내려받아야 하고, webNginxProxyTest는 Docker 컨테이너 런타임을 요구한다(§2). 이 분석은 컨테이너를 띄우지 않는다. 다만 §40.1이 확인했듯 그 레인들이 인증하는 것은 픽스처가 세운 컨텍스트이며, 이 분석의 발견은 그 레인들의 통과와 양립한다.


51. 분석 후 정정 (2026-08-31, 교차 스코프 분석 중)

교차 스코프 분석에서 저장소 전체의 AutoConfiguration.imports 7개 파일과 app-bootstrapspring.factories를 전수로 다시 읽고 §8.1의 서술을 정정했다.

정정 전(잘못된 표현): "그것을 넘겨받을 자동설정은 작성되지 않았다." 정정 후(확인된 사실): 자동설정 두 개(WebMvcPlatformAutoConfiguration · WebFluxPlatformAutoConfiguration)는 존재하고, 이 leaf의 .imports에 등록돼 있으며, app-bootstrap이 이 leaf를 implementation으로 물고 있고 저장소의 유일한 AutoConfigurationImportFilter가 JPA 전용이므로 출하 컨텍스트에 실제로 import된다. 두 자동설정은 @Bean 13개·10개를 등록한다.

결함 자체는 그대로이고 오히려 더 좁고 선명해진다 — 등록되는 13+10개는 전부 협력자(ProblemCatalog·WebProblemFactory·WebBudgetCatalog·InMemoryWebOperationCatalog·정책·매퍼·필터)이고, 스캔에서 제외된 다섯 패키지의 여섯 컴포넌트(problem advice 2 · budget advice 1 · budget filter 1 · operation controller 2)는 어느 자동설정도 @Bean으로도 @Import로도 소유하지 않는다. 여섯 전부 main 참조 0이다.

따라서 실패 시나리오·권고·§16.3·§20.2·§48.1과의 연결은 모두 유효하다. 바뀐 것은 원인의 위치다 — "자동설정이 없다"가 아니라 "자동설정이 의존만 소유하고 통제를 소유하지 않는다"이며, 권고(두 자동설정이 두 핸들러를 등록하도록)는 정정 후에 더 작은 변경이 된다: 새 자동설정을 만드는 것이 아니라 기존 자동설정에 @Bean 여섯 개를 추가하는 일이다.

빈 개수도 정정한다 — 본문의 "12개·11개"는 실측 **13개(MVC)·10개(WebFlux)**다.

증거: evidence/raw/264-cross-scope-autoconfiguration-roots.txt

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 189개 (main 152 · test 26 · 기타 11).

src/adapter/inbound/web/build.gradle
src/config/architecture/modules.json                    (adapter-inbound-web 항목)

main:
  src/main/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformSnapshot.java
  src/main/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformStartupValidator.java
  src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/SpringMvcRouteInventoryCollector.java
  src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteContract.java
  src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteInventory.java
  src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionDecision.java
  src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionPermit.java
  src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/web/admission/SemaphoreAdmissionController.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/WebAdvancedFeature.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/WebAdvancedFeatureFlags.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/RepresentationNegotiationPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/SecureXmlInputFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebCborMapperFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebRepresentation.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebXmlMapperFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/WebFunctionalHandlerAdapter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcStreamingExecutorConfiguration.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/VirtualThreadMvcConfiguration.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/VirtualThreadSettings.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPointerAuthorization.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchFieldAuthorization.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/GapAndDuplicateGuard.java
  src/main/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/VirtualThreadProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/web/auth/AuthenticatedPrincipal.java
  src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtToAuthenticatedPrincipalConverter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/auth/RestrictedPathRule.java
  src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java
  src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetCatalog.java
  src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetMeter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebRequestBudget.java
  src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicyCatalog.java
  src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebVaryPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ConditionalReadEvaluator.java
  src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ETags.java
  src/main/java/dev/caskeleton/adapter/inbound/web/conditional/EntityTag.java
  src/main/java/dev/caskeleton/adapter/inbound/web/conditional/EntityTagCodec.java
  src/main/java/dev/caskeleton/adapter/inbound/web/conditional/MutationPreconditionEvaluator.java
  src/main/java/dev/caskeleton/adapter/inbound/web/conditional/WebPreconditionFailedException.java
  src/main/java/dev/caskeleton/adapter/inbound/web/core/ActorContext.java
  src/main/java/dev/caskeleton/adapter/inbound/web/core/ExternalRequestContext.java
  src/main/java/dev/caskeleton/adapter/inbound/web/core/TenantContext.java
  src/main/java/dev/caskeleton/adapter/inbound/web/core/WebOperationName.java
  src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRequestContext.java
  src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRequestId.java
  src/main/java/dev/caskeleton/adapter/inbound/web/core/WebTraceId.java
  src/main/java/dev/caskeleton/adapter/inbound/web/cursor/CursorException.java
  src/main/java/dev/caskeleton/adapter/inbound/web/envelope/EnvelopeBodyAdvice.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/BudgetProblemMapper.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/ClientSafeErrorMessages.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/ErrorResponseFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemCatalog.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemCode.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/SafeProblemDetailExtensions.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/ThrottleProblemWriter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/ValidationIssue.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblem.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblemFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblemSanitizer.java
  src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebExecutionEvidenceTracker.java
  src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/config/MvcTransferExecutorConfiguration.java
  src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/http/ZeroCopyEligibility.java
  src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/nginx/DefaultNginxInternalUriMapper.java
  src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/nginx/NginxDownloadStrategy.java
  src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/nginx/NginxInternalUriMapper.java
  src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/problem/FileserverExceptionHandler.java
  src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/problem/FileserverProblem.java
  src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/problem/FileserverProblemFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/reactive/FileserverReactiveConfiguration.java
  src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/security/FileserverRequestContextFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/tus/TusChecksumVerifier.java
  src/main/java/dev/caskeleton/adapter/inbound/web/filter/CacheControlFilter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/filter/RequestLoggingFilter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalOrigin.java
  src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalPrefix.java
  src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriBuilder.java
  src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/FingerprintHeaderPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKeySupport.java
  src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/SemanticRequestFingerprintFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/WebIdempotencyGate.java
  src/main/java/dev/caskeleton/adapter/inbound/web/json/BoundedJsonFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/web/json/WebJsonProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/web/json/WebObjectMapperFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebStableModule.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformAutoConfiguration.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformSettings.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/BoundedHttpServletRequest.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/BoundedHttpServletResponse.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/WebMvcBudgetExceptionHandler.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/WebMvcBudgetFilter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/context/WebMvcRequestContextArgumentResolver.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/context/WebMvcRequestContextHolder.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/error/WebMvcProblemExceptionHandler.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcEvidenceFilter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcRequestIdFilter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/operation/OperationHttpController.java
  src/main/java/dev/caskeleton/adapter/inbound/web/mvc/throttle/WebMvcThrottleFilter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/ExternalRequestUrlResolver.java
  src/main/java/dev/caskeleton/adapter/inbound/web/observability/HeaderSanitizer.java
  src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAccessLogger.java
  src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditAction.java
  src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditEvent.java
  src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditPublisher.java
  src/main/java/dev/caskeleton/adapter/inbound/web/openapi/CursorSchemaContributor.java
  src/main/java/dev/caskeleton/adapter/inbound/web/openapi/ProblemSchemaContributor.java
  src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiBreakingPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiDiffResult.java
  src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiReleaseGate.java
  src/main/java/dev/caskeleton/adapter/inbound/web/operation/InMemoryWebOperationCatalog.java
  src/main/java/dev/caskeleton/adapter/inbound/web/operation/WebOperationCatalog.java
  src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationAccessPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationQueryService.java
  src/main/java/dev/caskeleton/adapter/inbound/web/pagination/FilterFingerprint.java
  src/main/java/dev/caskeleton/adapter/inbound/web/pagination/HmacWebCursorCodec.java
  src/main/java/dev/caskeleton/adapter/inbound/web/pagination/PageValidationException.java
  src/main/java/dev/caskeleton/adapter/inbound/web/pagination/UnsupportedQueryVocabularyException.java
  src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorCodec.java
  src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorException.java
  src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorKeyRing.java
  src/main/java/dev/caskeleton/adapter/inbound/web/proxy/ForwardedHeaderSanitizer.java
  src/main/java/dev/caskeleton/adapter/inbound/web/proxy/NormalizedForwardedHeaders.java
  src/main/java/dev/caskeleton/adapter/inbound/web/proxy/TrustedProxyPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridge.java
  src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptor.java
  src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfig.java
  src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizer.java
  src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/WebRateLimiter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/security/AuthenticationView.java
  src/main/java/dev/caskeleton/adapter/inbound/web/security/SecurityIdentity.java
  src/main/java/dev/caskeleton/adapter/inbound/web/security/UntrustedTenantInputException.java
  src/main/java/dev/caskeleton/adapter/inbound/web/security/WebActorContextResolver.java
  src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCorsPolicyValidator.java
  src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCsrfPolicyResolver.java
  src/main/java/dev/caskeleton/adapter/inbound/web/security/WebSecurityContextBridge.java
  src/main/java/dev/caskeleton/adapter/inbound/web/security/WebTenantContextResolver.java
  src/main/java/dev/caskeleton/adapter/inbound/web/settings/CorsSettings.java
  src/main/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettings.java
  src/main/java/dev/caskeleton/adapter/inbound/web/versioning/PathApiVersionResolver.java
  src/main/java/dev/caskeleton/adapter/inbound/web/versioning/SunsetViolationException.java
  src/main/java/dev/caskeleton/adapter/inbound/web/versioning/UnsupportedApiVersionException.java
  src/main/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformAutoConfiguration.java
  src/main/java/dev/caskeleton/adapter/inbound/web/webflux/budget/BoundedServerWebExchange.java
  src/main/java/dev/caskeleton/adapter/inbound/web/webflux/budget/WebFluxBudgetFilter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextAccessor.java
  src/main/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextFilter.java
  src/main/java/dev/caskeleton/adapter/inbound/web/webflux/error/WebFluxProblemExceptionHandler.java
  src/main/java/dev/caskeleton/adapter/inbound/web/webflux/operation/ReactiveOperationHttpController.java
  src/main/java/dev/caskeleton/adapter/inbound/web/webflux/throttle/WebFluxThrottleFilter.java

test:
  src/test/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedRollbackIT.java
  src/test/java/dev/caskeleton/adapter/inbound/web/error/NoResourceFoundErrorHandlingTest.java
  src/test/java/dev/caskeleton/adapter/inbound/web/error/ProblemCatalogTest.java
  src/test/java/dev/caskeleton/adapter/inbound/web/error/WebProblemFactoryTest.java
  src/test/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebModuleBoundaryTest.java
  src/test/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformAutoConfigurationTest.java
  src/test/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiReleaseGateTest.java
  src/test/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiSnapshotTest.java
  src/test/java/dev/caskeleton/adapter/inbound/web/validation/WebValidationExceptionMapperTest.java
  src/test/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformAutoConfigurationTest.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/WebArchitectureRules.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/WebBudgetContract.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebPlatformContractRecording.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebPlatformContractSuite.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WireOutcome.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WireProbe.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/HttpResponseLossFixture.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/IdempotencyResponseLossContract.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/ResponseLossFixture.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/WebFaultInjector.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/OperationHttpContract.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebPipelineOrderContract.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/WebLoadAndShutdownContract.java
  src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/WebThrottleHttpContract.java
  src/testkit/java/dev/caskeleton/webtestkit/BudgetFixtureApplication.java
  src/testkit/java/dev/caskeleton/webtestkit/ThrottleFixtureApplication.java

기타:
  CLAUDE.md
  README.md
  src/app-bootstrap/build.gradle
  src/build.gradle
  src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/JettyWebBudgetIT.java
  src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/JettyWebThrottleIT.java
  src/sample-portfolio/build.gradle
  src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveBudgetFixtureApplication.java
  src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveWebBudgetIT.java
  src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ReactiveThrottleFixtureApplication.java
  src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ReactiveWebThrottleIT.java

해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등:
  application.yml
  evidence/raw/203-composition-root-scan-boundary.txt
  evidence/raw/188-inbound-web-module-inventory.txt
  evidence/raw/189-inbound-web-governance-probes.txt
  evidence/raw/190-inbound-web-error-probes.txt
  evidence/raw/191-inbound-web-security-probes.txt
  evidence/raw/193-inbound-web-capacity-probes.txt
  evidence/raw/192-inbound-web-package-reachability.txt
  evidence/raw/194-inbound-web-idempotency-probes.txt
  evidence/raw/195-inbound-web-representation-probes.txt
  evidence/raw/196-inbound-web-codec-probes.txt
  evidence/raw/197-inbound-web-observability-probes.txt


A15. adapter-inbound-grpc

분석 중에는 15-adapter-inbound-grpc.md 파일이었다. 289줄.

adapter-inbound-grpc — 코드베이스 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-inbound-grpc
  • canonical state analysisFile: §A15 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/inbound/grpc · Gradle :adapter:inbound:grpc
  • registry allowed_dependencies: ["domain-core", "application-core", "shared-contract"]
  • registry runtime_memberships: []
  • coverage ledger: FULL_READ 18 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


분석 대상 src/adapter/inbound/grpc · revision a24ece9cf797f7ea647e33bf846b115208ed1ba5 분모 18 tracked files (main 8 · test 6 · governance 4) — 단일 bounded scope LOC main Java 602 · test Java 782 근거 evidence/raw/204-inbound-grpc-probes.txt (file_count=18)

1. 커버리지 원장

# scope main test governance 상태
1 모듈 전체 (단일 bounded scope) 8 6 4 18 COMPLETE

FULL_READ 18 / 18 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0.

지금까지 분석한 15개 모듈 중 가장 작다. inbound-web(638)의 2.8%이고, main 파일이 8개라 sub-scope 분할이 의미를 갖지 않는다.

2. 무엇을 하는 코드인가

전송 인프라만. 서버 수명주기 · 타입드 설정 · 인증 정책 경계 · 에러 매핑, 그리고 .proto 없이 부팅하는 최소 표면(standard health, 명시 opt-in 시 reflection).

build.gradle의 첫 세 결정이 이 모듈의 성격을 정한다:

  • third-party starter 없음. SmartLifecycle 빈(GrpcServerRunner)이 io.grpc Netty 서버를 직접 소유한다 — "so this module depends on NO third-party grpc-spring-boot starter (no Spring Boot version coupling)."
  • protobuf 컴파일 없음. com.google.protobuf 플러그인도 .proto도 없다. health와 reflection은 grpc-services가 런타임에 제공하고, 향후 feature가 자기 .proto를 소유한다.
  • BOM을 모듈 스코프에서 import. io.grpc:*/protobuf 버전은 Spring Boot BOM이 관리하지 않으므로 grpc-bom/protobuf-bom을 여기서 가져온다 — "this keeps the strict-locking blast radius to this module (the shared root dependencyManagement block stays io.grpc-free)."

feature-agnostic 등록. GrpcServerRunner가 모든 BindableService 빈을 ObjectProvider로 받아 이름을 모른 채 등록한다. 그리고 그 등록에 조건을 건다:

// GrpcServerRunner.start()
if (!featureServices.isEmpty() && policies.size() != 1) {
  throw new IllegalStateException(
      "feature gRPC services require exactly one caller-supplied authentication policy");
}

feature 서비스가 하나라도 있으면 caller-supplied 인증 정책이 정확히 하나 있어야 하고, 없거나 둘 이상이면 listener가 시작되지 않는다. 그래서 "인증 없이 노출된 RPC"가 구조적으로 불가능하다.

활성화가 삼중으로 닫혀 있다.

// GrpcServerConfig
@ConditionalOnProperty(prefix = "ca-skeleton.grpc", name = "enabled",
                       havingValue = "true", matchIfMissing = false)

// GrpcServerProperties
@ConfigurationProperties(prefix = "ca-skeleton.grpc", ignoreUnknownFields = false)
@Validated
@AssertTrue(message = "insecure gRPC requires allow-insecure-local=true and a loopback bind address")
public boolean isInsecureLocalConfigurationValid() {
  return !enabled || (allowInsecureLocal && isLoopbackBindAddress());
}

현재 transport credential이 plaintext뿐이므로, enabled=trueallowInsecureLocal=true실제로 loopback으로 해석되는 bind address를 함께 요구한다. isLoopbackBindAddress()는 문자열 비교가 아니라 InetAddress.getByName(...).isLoopbackAddress()로 판정하므로 localhost·127.0.0.2·::1이 모두 통과하고 0.0.0.0은 통과하지 않는다.

에러 계약. GrpcStatusMapper가 10값 Category SSOT를 gRPC Status로 옮기고, 정확한 codecategory는 트레일러(error-code / error-category)에 싣는다 — "the wire status (like an HTTP status) is coarse, while the exact ApiErrorCode.code() and the category name ride in the trailers."

GrpcExceptionHandlingInterceptor가 그 계약의 단일 지점이다. 네 개의 서로 다른 실패 경로를 하나의 sanitizer로 모은다:

경로 처리
next.startCall 이 던짐 :47-50 catch → closeWithError
리스너 콜백(onMessage·onHalfClose·onReady·onCancel·onComplete)이 던짐 runGuardedcloseWithError
feature가 responseObserver.onError(...) ServerCallscall.close(...) → sanitizing override
feature가 raw StatusRuntimeException 같은 override

그리고 override가 모든 non-OK close를 재작성한다:

// sanitizingCall(...).close
if (status.isOk()) { super.close(status, trailers); return; }
ApiErrorCode code = errorCodeOf(status.getCause());
if (code == null) { code = OperationalError.INTERNAL_ERROR; }
super.close(statusMapper.toStatus(code.category()).withDescription(code.code()),
            statusMapper.trailersFor(code));

호출자가 넘긴 description과 트레일러는 버려진다. javadoc이 그 이유를 적는다 — "raw descriptions and input trailers, which may carry a SQLState or upstream detail, are never surfaced." closeWithErrorStatus.fromThrowable(exception)를 쓰는데도 원문이 새지 않는 것은 두 호출 지점(:48 · :84)이 모두 sanitizingCall에 대고 부르기 때문이다.

3. Negative-space probes

3.1 (8.1) 도달성 — feature 표면이 존재하는가
$ grep -rn 'BindableService|GrpcAuthenticationPolicy' --include=*.java .  (grpc leaf 제외)   -> 0
$ grep -rn 'ca-skeleton.grpc' --include=*.yml --include=*.yaml .                            -> 0
$ grep -rn 'inbound.grpc' --include=*.java app-bootstrap/src/main
app-bootstrap/.../CaSkeletonApplication.java:71:      "dev.caskeleton.adapter.inbound.grpc",

BindableService 구현도, GrpcAuthenticationPolicy 구현도, ca-skeleton.grpc 설정값도 저장소에 없다. app-bootstrap이 이 leaf를 언급하는 곳은 @ConfigurationPropertiesScan 목록 한 줄뿐이다.

이것은 결함이 아니라 선언된 상태다. CLAUDE.md가 명시한다 — "현재 저장소에는 production feature RPC나 sample gRPC service가 없다. 향후 feature를 도입할 때는 .proto/generated stub/BindableService를 해당 feature가 소유하고, 서비스 빈과 정확히 한 개의 caller-supplied GrpcAuthenticationPolicy 빈을 함께 제공한다." 기본값이 enabled=false이므로 출하 배포에서 리스너가 뜨지 않는 것도 의도다.

앞선 두 모듈(notification · inbound-web)에서 반복해서 만난 "장치는 있고 회로가 닫히지 않았다"와 형태가 비슷해 보이지만 다르다: 저기서는 플랫폼이 설치해야 할 것을 설치하지 않았고, 여기서는 채택자가 기여할 자리를 비워 둔 것이며 그 사실이 문서와 기본값과 테스트(missingActivationPropertyCreatesNoGrpcRuntimeBeansOrListener)에 함께 적혀 있다.

3.2 (8.2) 조건 형제 비교 — cause chain 순회 관용구가 저장소에 두 가지다

GrpcExceptionHandlingInterceptor.errorCodeOf가 원인 사슬을 훑는다:

private static ApiErrorCode errorCodeOf(Throwable throwable) {
  Throwable current = throwable;
  while (current != null) {
    if (current instanceof ApiErrorCarrier carrier) { return carrier.errorCode(); }
    if (current.getCause() == current) { break; }      // 자기참조만 감지
    current = current.getCause();
  }
  return null;
}

같은 일을 하는 코드가 저장소에 아홉 곳 있고 두 갈래로 갈린다:

관용구 위치
깊이 제한 (어떤 순환에도 안전) web/auth/JwtDecoderConfig:63(32) · notification/.../NotificationSchedulerWorker:124(8) · notification/.../JdkNotificationHttpGateway:98(10) · mongo/.../SpringDataBulkFailureExtractor:30 · mongo/failure/MongoFailureExtractor:41
자기참조 검사만 (2-순환에서 무한 루프) grpc/GrpcExceptionHandlingInterceptor:124 · web/advanced/mvc/MvcDisconnectDetector:61 · web/advanced/webflux/WebFluxDisconnectDetector:66 · persistence-jpa/.../TransactionRetryClassifier:19

§4.1.

3.3 (8.3) 중복 메커니즘 — 인증과 예외 처리의 인터셉터 순서

ServerInterceptors.intercept(service, exceptionInterceptor, authenticationInterceptor) — gRPC 규약상 마지막 인터셉터의 interceptCall이 먼저 호출되므로 인증이 바깥, 예외 처리가 안쪽이다.

인증 인터셉터가 예외 처리 바깥에 있는데도 안전한 이유는 그것이 스스로 예외를 삼키기 때문이다:

private boolean isAuthenticated(Metadata headers) {
  try { return authenticationPolicy.isAuthenticated(headers); }
  catch (RuntimeException ignored) { return false; }
}

CLAUDE.md의 약속("정책이 false를 반환하거나 예외를 던진 요청은 ... 안정적인 UNAUTHENTICATED status/code/category로 종료된다")이 코드와 일치하고, 두 경우 모두 같은 call.close(Status.UNAUTHENTICATED.withDescription(OperationalError.UNAUTHENTICATED.code()), trailersFor(...))로 끝난다. 정책 진단은 클라이언트에 닿지 않는다. 중복 아님.

3.4 (8.4) 문서/구현 드리프트

설정 표. CLAUDE.md의 여섯 개 knob(enabled·port·bindAddress·allowInsecureLocal·reflectionEnabled·shutdownGraceSeconds)과 기본값이 GrpcServerProperties의 필드·기본값과 정확히 일치한다. port0..65535 범위 설명도 @Min(0) @Max(65535)와 일치한다. 드리프트 없음.

Category 망라. GrpcStatusMapper.toStatus가 10개 값을 전부 다루고 default 분기가 없다 — 값이 추가되면 컴파일이 깨진다. 그리고 테스트 coversEveryCategoryValue가 그것을 별도로 고정한다.

컴포지션 루트 규칙과의 어긋남. CaSkeletonApplication의 javadoc은 이렇게 선언한다:

"The five optional adapters are absent from the list below on purpose. Each one's settings are registered by its capability root through @EnableConfigurationProperties, which is what ties binding to the master switch. Adding a package back here would restore the binding and silently undo the gate."

그런데 dev.caskeleton.adapter.inbound.grpc는 그 목록(@ConfigurationPropertiesScan)에 있고, GrpcServerConfig@EnableConfigurationProperties(GrpcServerProperties.class)쓴다. §4.2.

health 상태 시점. GrpcServerRunner.start()healthStatusManager.setStatus(SERVICE_NAME_ALL_SERVICES, SERVING)server = builder.build().start() 앞에서 부른다. §4.3.

4. Findings

4.1 P2 — 원인 사슬 순회가 2-순환에서 무한 루프에 빠지고, 저장소는 이미 그 사례를 이름으로 적어 두었다

errorCodeOf의 종료 조건은 current.getCause() == current 하나다. 서로를 원인으로 갖는 두 예외(a.cause = b, b.cause = a)에서는 이 조건이 참이 되지 않고 current가 a→b→a→b로 무한히 순환한다. 이 사슬은 평범한 자바로 구성 가능하다 — a = new RuntimeException(); b = new RuntimeException(a); a.initCause(b);.

같은 저장소가 이 정확한 위험을 다른 모듈에서 이름으로 서술하고 다른 관용구를 택했다:

JdkNotificationHttpGateway:93-97 — "Depth-bounded rather than cycle-detecting: a cause chain can be circular (two exceptions each initCause'd to the other), and an unbounded walk over one hangs the dispatch thread. Ten is far deeper than any real transport wrapping."

즉 이 주석은 자기참조 검사가 놓치는 바로 그 경우를 지목하고, 깊이 제한을 그 이유로 채택한다. 저장소의 아홉 개 순회 지점 중 다섯이 깊이 제한이고 넷이 자기참조 검사다(§3.2).

실패 시나리오 — feature gRPC 서비스가 순환 원인 사슬을 가진 라이브러리 예외를 전파한다(일부 커넥션 풀과 재시도 래퍼가 실패 원인을 상호 참조하는 형태로 만든다). closeWithError가 그 예외를 Status.withCause에 실어 sanitizing close로 보내고, errorCodeOf가 진입해 돌아오지 않는다. gRPC 핸들러 스레드 하나가 CPU를 태우며 멈추고, 클라이언트는 응답도 상태도 받지 못한 채 데드라인까지 기다린다. 같은 예외가 반복되면 서버 스레드가 하나씩 소진된다.

나머지 세 지점의 영향도MvcDisconnectDetectorWebFluxDisconnectDetector는 요청 처리 중 클라이언트 연결 끊김을 판정하는 곳이고, TransactionRetryClassifier는 트랜잭션 재시도 여부를 판정하는 곳이다. 셋 다 요청 스레드 위에서 실행된다.

권고 — 네 지점을 깊이 제한으로 통일한다. JdkNotificationHttpGateway의 형태가 이미 정본이고 그 근거까지 코드에 있다. 이 leaf에서는 errorCodeOfwhilefor (int depth = 0; current != null && depth < 16; depth++, current = current.getCause())로 바꾸면 닫힌다.

4.2 P3 — 설정 바인딩이 마스터 스위치 밖에서 일어난다. 컴포지션 루트의 자기 규칙과 어긋난다

§3.4. GrpcServerProperties는 두 경로로 등록된다 — GrpcServerConfig@EnableConfigurationProperties(게이트 안쪽)와 CaSkeletonApplication@ConfigurationPropertiesScan(게이트 바깥). 후자가 있으면 ca-skeleton.grpc.enabled와 무관하게 바인딩이 일어난다.

CaSkeletonApplication의 javadoc은 이 구조가 과거에 만든 사고를 기록한다 — "The asymmetry that existed before — beans gated, settings not — is why a notification settings object bound itself in a deployment whose notification master was off." 그리고 그 교훈을 다섯 optional 어댑터에 적용하면서 grpc·web·websocket은 목록에 남겼다.

지금 이 leaf에서는 무해하다. 검증이 전부 게이트를 존중하거나 안전한 기본값을 갖는다:

검증 enabled=false에서
@AssertTrue isInsecureLocalConfigurationValid() !enabled 로 즉시 참
@Min(0) @Max(65535) port 기본 9090
@NotBlank bindAddress 기본 127.0.0.1
@Min(0) shutdownGraceSeconds 기본 5

부작용은 두 가지뿐이다: (a) 비활성 배포에서도 프로퍼티 빈이 만들어진다, (b) ignoreUnknownFields = false이므로 ca-skeleton.grpc.* 아래 오타 하나가 gRPC를 쓰지 않는 배포의 부팅을 실패시킨다. (b)는 오히려 바람직한 쪽에 가깝다.

기록하는 이유는 규칙과 적용이 갈린다는 점이다. 같은 javadoc이 "Adding a package back here would restore the binding and silently undo the gate"라고 경고하고, 이 패키지가 그 목록에 있다. 지금 이 leaf가 안전한 것은 규칙이 지켜져서가 아니라 기본값이 전부 유효하기 때문이고, 새 검증이 하나 추가되면 그 보호막이 사라진다.

4.3 P3/기록 — health 가 바인드 이전에 SERVING 으로 선언된다
// GrpcServerRunner.start()
healthStatusManager.setStatus(SERVICE_NAME_ALL_SERVICES, ServingStatus.SERVING);
builder.addService(healthStatusManager.getHealthService());
...
server = builder.build().start();     // 이 뒤에야 실제로 바인드된다

start()IOException으로 실패하면 UncheckedIOException이 던져지고 컨텍스트 시작이 실패하므로, "SERVING인데 서버가 없다"는 상태가 관측되는 창은 없다. 다만 이 순서는 health를 "프로세스가 살아 있음"이 아니라 "서비스가 준비됨"으로 쓰는 배포에서 의미가 없어진다 — 값이 항상 SERVING이고 어떤 조건에서도 NOT_SERVING이 되지 않는다(종료 시 enterTerminalState() 하나 제외).

feature 서비스가 없는 현재 상태에서는 판단할 근거가 없고, feature가 들어올 때 "무엇이 준비되면 SERVING인가"를 정해야 한다는 기록이다.

4.4 P3/기록 — raw gRPC status 를 INTERNAL 로 강등하는 것은 의도이며, 표준 관용구를 막는다

responseObserver.onError(Status.NOT_FOUND.asRuntimeException())은 gRPC의 표준 오류 보고 방식이지만, 이 인터셉터에서는 status.getCause()ApiErrorCarrier가 아니므로 INTERNAL + INTERNAL_ERROR로 재작성된다. javadoc이 그것을 명시하고("An unrecognised exception or raw gRPC status maps to Status.INTERNAL") 테스트 rawStatusRuntimeExceptionIsSanitizedToInternal이 고정한다.

즉 이 플랫폼에서 non-INTERNAL 오류를 내는 유일한 방법은 ApiErrorCarrier(보통 ApiErrorException)를 던지는 것이다. 강한 의견이고 문서화돼 있으므로 결함이 아니다. feature 개발자가 표준 관용구를 쓰면 조용히 INTERNAL이 된다는 사실만 기록한다 — CLAUDE.md의 "Feature 기여 방법" 절에 그 규칙이 없다.

5. 실행 검증

$ ./gradlew :adapter:inbound:grpc:test :adapter:inbound:grpc:grpcTransportQualificationTest
> Task :adapter:inbound:grpc:grpcTransportQualificationTestEvidence
grpcTransportQualificationTest: 15 tests, 0 skipped
BUILD SUCCESSFUL in 12s
GRADLE_EXIT=0

test-results 집계: classes=8 tests=48 failures=0 errors=0 skipped=0

registerStrictQualificationTest가 두 클래스(GrpcSafeActivationTest · GrpcP1BoundaryWireTest)를 이름으로 요구하고 skip 0을 강제한다. 24개 테스트 이름이 활성화 기본값 · 설정 검증 4종 · 인증 3종 · 네 개 오류 경로 · reflection off · Category 전수를 덮는다.

GrpcP1BoundaryWireTest가 실제 loopback ephemeral Netty 서버를 띄워 와이어 수준에서 확인한다는 점이 중요하다 — 이 모듈의 계약은 인터셉터 조합 순서에 의존하고(§3.3), 그것은 목으로 재현되지 않는다.

6. 종합

결함 밀도가 이 저장소에서 가장 낮은 모듈이다. main 8파일 602 LOC에 P1 0건, P2 1건(그것도 저장소 전반의 관용구 분열이 이 지점에 나타난 것), P3 3건.

세 가지가 이 결과를 만든다:

  1. 범위가 좁고 그 경계가 문서에 있다. "전송 인프라만, feature RPC 없음"이 CLAUDE.md의 Responsibility·Forbidden 두 절에 명시되고, 코드에 feature 흔적이 없다.
  2. 활성화가 삼중으로 닫혀 있다. 프로퍼티 게이트 · @AssertTrue 교차 검증 · feature 서비스가 있을 때 인증 정책을 강제하는 런타임 검사. 셋 다 fail-closed이고 셋 다 테스트가 있다.
  3. 검증이 와이어 수준이다. 실제 Netty 서버 · 실제 loopback 소켓 · skip 0 강제. inbound-web에서 확인한 "픽스처가 조립하고 레인이 픽스처를 인증한다"는 형태가 여기서는 성립하지 않는다 — 조립할 것이 GrpcServerConfig 하나이고 그것을 테스트가 직접 켜기 때문이다.

앞 모듈들과의 대조. inbound-web은 397개 main 파일 중 대부분이 조립되지 않았고 그것을 검증 장치가 가렸다. 여기서는 조립할 것이 여덟 개뿐이고 전부 하나의 @Configuration에 있으며, 그 @Configuration이 켜지는지 꺼지는지를 두 테스트가 양방향으로 확인한다. 모듈 크기가 아니라 조립 지점의 수가 이 차이를 만든다.

7. 완료 게이트

  • denominator 18 / 18 FULL_READ (probe가 file_count=18 확인)
  • §8.1~§8.4 negative-space probe 수행 — 도달성(선언된 빈 상태 확인) · 조건 형제 비교(저장소 전체 관용구 전수) · 중복 메커니즘(인터셉터 순서) · 문서 드리프트(설정 표 · Category 망라 · 컴포지션 루트 규칙)
  • 실행 검증: :test + :grpcTransportQualificationTestBUILD SUCCESSFUL, tests=48 failures=0 skipped=0
  • 거짓 양성 후보 검증 후 기각: ServerInterceptors.intercept 인자 순서(→ 인증이 바깥이지만 자기 예외를 삼키므로 안전) · closeWithErrorStatus.fromThrowable(→ 두 호출 지점이 모두 sanitizingCall이라 원문이 재작성됨) · authenticationInterceptor가 null일 가능성(→ feature 서비스가 없을 때만 null이고 그때는 루프가 돌지 않음) · ApiErrorException.errorCodetransient(→ gRPC는 자바 직렬화를 쓰지 않음)
  • 소스 미변경

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 10개 (main 7 · test 2 · 기타 1).

src/adapter/inbound/grpc/build.gradle
src/config/architecture/modules.json                    (adapter-inbound-grpc 항목)

main:
  src/main/java/dev/caskeleton/adapter/inbound/grpc/ApiErrorException.java
  src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcAuthenticationPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcExceptionHandlingInterceptor.java
  src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerConfig.java
  src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerProperties.java
  src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerRunner.java
  src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcStatusMapper.java

test:
  src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcP1BoundaryWireTest.java
  src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcSafeActivationTest.java

기타:
  src/build.gradle

해석되지 않은 인용 (1종) — 외부 타입·문서상 약칭 등:
  evidence/raw/204-inbound-grpc-probes.txt


A16. adapter-inbound-graphql

분석 중에는 16-adapter-inbound-graphql.md 파일이었다. 1,337줄.

adapter-inbound-graphql — 코드베이스 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-inbound-graphql
  • canonical state analysisFile: §A16 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/inbound/graphql · Gradle :adapter:inbound:graphql
  • registry allowed_dependencies: ["domain-core", "application-core", "shared-contract"]
  • registry runtime_memberships: ["app-bootstrap"]
  • coverage ledger: FULL_READ 534 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


분석 대상 src/adapter/inbound/graphql · revision a24ece9cf797f7ea647e33bf846b115208ed1ba5 분모 534 tracked files (main 411 · test 103 · testFixtures 16 · governance 4) LOC main Java 26,303 · test Java 13,671 근거 evidence/raw/205-inbound-graphql-module-inventory.txt, 패키지 도달성 지도 206-...

0. 이 모듈의 형태

main 411 파일 26,303 LOC로 inbound-web(400 / 27,473) 다음으로 크다. 그러나 조립 구조가 근본적으로 다르다.

컴포지션 루트는 이 leaf를 컴포넌트 스캔에서 통째로 제외한다:

// CaSkeletonApplication.AUTO_CONFIGURED_PACKAGES
  |dev\.caskeleton\.adapter\.inbound\.graphql\..*

그래서 이 leaf의 어떤 @Controller@Component도 스캔으로 발견되지 않고, 조립은 전적으로 자동설정 진입점 하나에 달려 있다:

META-INF/spring/...AutoConfiguration.imports
  dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRootAutoConfiguration

META-INF/spring.factories
  AutoConfigurationImportFilter   = ...GraphQlOffAutoConfigurationImportFilter
  EnvironmentPostProcessor        = ...GraphQlActivationEnvironmentPostProcessor

app-bootstrapsample-portfolio가 이 leaf에서 import하는 타입은 0개다(도달성 지도 ext 열 전부 0). 배선이 전부 leaf 안에 있다.

그리고 이 leaf는 inbound-web이 겪고 있는 결함을 이미 한 번 겪고 고쳤다. GraphQlRootAutoConfiguration@Import 목록에 달린 주석이 그것이다:

"The resolver for the schema's only field. It is a @Controller in a package the composition root's component scan excludes by regex — the exclusion that makes this capability optional — and no root imported it, so a deployment with GraphQL on served a schema declaring _health: String! with nothing to resolve it. Every query answered NullValueInNonNullableField. Its own tests passed throughout by registering the class themselves, which is the shape of the defect rather than a defence against it."

inbound-web §8.1에서 확인한 상태 — 스캔에서 빼고 자동설정이 넘겨받지 않아 컨트롤/핸들러가 사라진 상태, 그리고 테스트가 그것을 스스로 등록해 통과하는 상태 — 를 이 모듈은 이름 붙여 진단하고 닫았다.

1. 커버리지 원장

# sub-scope main test 기타 상태
1 governance + autoconfigure + moduleboundary + architecture + api 35 21 4 60 COMPLETE
2 schema + scalar + compat 37 9 46 COMPLETE
3 execution + context + runtime 48 12 60 COMPLETE
4 cost + policy + security 45 12 57 COMPLETE
5 http + error + observation 38 10 48 COMPLETE
6 dataloader + fetch + pagination + mutation 58 11 69 COMPLETE
7 release 9 1 10 COMPLETE
8 advanced/ 스트리밍 (subscription·websocket·sse·incremental·rsocket) 45 6 51 COMPLETE
9 advanced/ 요청 성형 (persisted·get·replay·chaining·admin) 46 7 53 COMPLETE
10 advanced/ 스키마·플랫폼 (federation·composition·codegen·springdata·security·release·bootstrap) 50 9 59 COMPLETE
11 testFixtures + test 잔여 0 5 16 21 COMPLETE
TOTAL 411 103 20 534 11 / 11

분할은 205-...의 패키지 트리에서 기계 계산했고 중복 0 · 미할당 0이다.


Sub-scope 01 — governance + autoconfigure + moduleboundary + architecture + api (60 files, main 35 + test 21 + governance 4)

내부 상태: COMPLETE — 60 / 60 FULL_READ · 근거 evidence/raw/207-inbound-graphql-autoconfigure-probes.txt (file_count=60)

2. 무엇을 하는 코드인가

조립 진입점이 하나다. .imports에 등록된 것은 GraphQlRootAutoConfiguration — 마스터 게이트를 든 얇은 루트이고, 실제 39개 빈은 그것이 @Import하는 GraphQlPlatformAutoConfiguration(704줄, @ConditionalOn* 43개)에 있다. 그 분리의 이유가 테스트로 고정돼 있다:

// GraphQlPlatformAutoConfigurationTest.theAutoConfigurationIsRegisteredInTheImportsMetadata
.as("the registered entry is the master-gated root, not the platform configuration it imports; "
  + "registering the platform directly is what let a context assemble a GraphQL endpoint that no switch had asked for")
.contains(GraphQlRootAutoConfiguration.class.getName())
.doesNotContain(GraphQlPlatformAutoConfiguration.class.getName() + "\n");

off 계약이 빈 부재만으로 성립하지 않는다는 것을 알고 있다. Spring GraphQL은 자기 자동설정에서 /graphql을 발행하므로 프로젝트 빈을 하나도 만들지 않아도 엔드포인트가 열린다. 그래서 spring.factoriesAutoConfigurationImportFilter를 하나 더 건다:

GraphQlOffAutoConfigurationImportFilter — "The GraphQL starter contributes its auto-configurations through Boot's import metadata, so an ordinary @EnableAutoConfiguration application publishes /graphql from the classpath alone, whatever any project condition says. That is the difference between an endpoint that is off and one whose project beans are absent while the framework serves it anyway. A bean-inventory assertion cannot see a route the framework published."

프레임워크 자동설정 10개를 이름으로 열거해 마스터가 꺼져 있으면 후보 집합에서 제거한다.

환경 후처리기가 두 가지를 부팅 전에 정리한다. GraphQlActivationEnvironmentPostProcessor(Ordered.LOWEST_PRECEDENCE, 프로파일 설정이 이미 기여된 뒤에 도는 이유까지 적혀 있다):

  1. 은퇴한 안전 키를 무시가 아니라 거부. backend.graphql.productionbackend.graphql.environment는 더 이상 record 컴포넌트가 아니고 Spring 바인더는 모르는 키를 조용히 넘긴다. GraphQlRetiredSafetyAxis가 그것을 부팅 실패로 바꾸는 이유가 정확하다 — "an operator who sets the key they have always set gets a clean startup and a silently different safety posture — which is a worse outcome than the split-brain being fixed, because the old configuration at least did something."
  2. 프레임워크 콘솔 플래그에 플랫폼 기본값을 최저 우선순위로 기여. Spring Boot는 introspection을 기본 허용하고 이 플랫폼은 허용하지 않아서, "turning GraphQL on failed at startup on a contradiction nobody had configured."

GraphQlPlatformStartupValidator가 12개 거부 규칙을 든다 — GraphiQL·introspection의 배포 모드별 금지, 서명 없는 커서 거부("unsigned cursors are client-editable"), 페이지/복잡도 상한 양수, 미지원 기능 4종(멀티파트 업로드 · HTTP 배열 배칭 · 요청 범위 트랜잭션 · 응답 캐싱), Stable 스타터가 Advanced를 켜는 것 금지.

3. Negative-space probes — sub-scope 01

3.1 (8.1) 도달성 — 컴포지션 루트와의 관계
$ grep -n 'graphql' CaSkeletonApplication.java
  |dev\.caskeleton\.adapter\.inbound\.graphql\..*        <- 컴포넌트 스캔 제외
$ grep -rn 'inbound.graphql' app-bootstrap/src/main sample-portfolio/src/main   -> 0

leaf 전체가 컴포넌트 스캔 밖이고 외부에서 import하는 타입이 0개다. 조립은 .imports 한 줄과 spring.factories 두 줄이 전부다.

이 구조에서 정확히 무엇이 잘못될 수 있는지를 이 leaf가 이미 겪고 적어 두었다:

// GraphQlRootAutoConfiguration @Import 주석
// The resolver for the schema's only field. It is a @Controller in a package the composition
// root's component scan excludes by regex — the exclusion that makes this capability optional —
// and no root imported it, so a deployment with GraphQL on served a schema declaring
// `_health: String!` with nothing to resolve it. Every query answered NullValueInNonNullableField.
// Its own tests passed throughout by registering the class themselves, which is the shape of the
// defect rather than a defence against it.

HealthGraphqlController가 지금 @Import 목록에 있다. 이것은 inbound-web §8.1과 같은 결함이고, 여기서는 진단되어 닫혔다. 두 모듈의 차이는 규모다 — web에서는 같은 형태가 다섯 패키지 23개 파일에 대해 열려 있다.

3.2 (8.2) 조건 형제 비교 — off 계약의 두 절반
절반 무엇을 막는가 검증
GraphQlRootAutoConfiguration@ConditionalOnProperty 이 leaf의 39개 빈 GraphQlShippedAndGatedTest.graphQlOffHoldsNothing — 빈 인벤토리
GraphQlOffAutoConfigurationImportFilter 프레임워크가 발행하는 /graphql 라우트 GraphQlShippedAndGatedTest.graphQlOffPublishesNoEndpoint — 실제 포트

두 번째 테스트가 특히 정교하다. 리터럴 404를 단언하지 않고, 매핑된 적 없는 경로의 상태 코드와 같은지를 본다 — 주석이 그 이유를 적는다: "Asserting a literal 404 would have been wrong: the security filter chain runs before ...". 그리고 그 테스트는 이 leaf가 아니라 app-bootstrap에 있다. off 계약은 출하 조립에서만 검증할 수 있으므로 옳은 위치다.

3.3 (8.3) 중복 메커니즘 — 마스터 스위치를 읽는 세 지점

backend.graphql.enabled를 읽는 곳이 셋이다: GraphQlRootAutoConfiguration@ConditionalOnProperty, GraphQlOffAutoConfigurationImportFilter.match, GraphQlRetiredSafetyAxis.problems. 셋 다 문자열 리터럴로 키를 갖는다.

같은 키를 세 곳이 문자열로 갖는 것은 드리프트 위험이지만, 세 지점이 서로 다른 생애주기(자동설정 조건 · import 필터 · 환경 후처리)에 있어 공유 상수를 두기 어렵다. 그리고 셋 중 하나라도 어긋나면 GraphQlShippedAndGatedTest의 두 단언 중 하나가 깨진다. 중복이되 검증으로 묶여 있다.

3.4 (8.4) 문서/카운트 드리프트 — 하드코딩된 프레임워크 자동설정 목록

GraphQlOffAutoConfigurationImportFilter가 프레임워크 자동설정 10개를 문자열 상수로 열거한다(Boot 4의 org.springframework.boot.graphql.autoconfigure.* 배치). javadoc이 위험을 스스로 밝힌다:

"A misspelled entry fails open silently — the filter simply never matches — so the test that protects this asserts a 404 on the real port rather than checking what this method returns."

그 테스트는 존재한다(§3.2). §4.1.

4. Sub-scope 01 findings

4.1 P3/기록 — 프레임워크 자동설정 목록이 하드코딩이고 드리프트 검사가 부분적이다

GraphQlOffAutoConfigurationImportFilter의 10개 이름은 Spring Boot 버전에 묶인 문자열이다. 보호 장치인 graphQlOffPublishesNoEndpoint/graphql 경로 하나를 실제 포트에서 확인하므로 다음을 잡는다:

  • 목록의 오타나 이름 변경으로 GraphQlWebMvcAutoConfiguration이 통과하는 경우 → /graphql이 열리고 테스트가 깨진다.

그리고 다음은 잡지 못한다:

  • Boot 업그레이드가 새 GraphQL 자동설정을 추가하고 그것이 /graphql 이외의 경로(예: RSocket 라우트, SSE 엔드포인트, 새 콘솔 경로)를 발행하는 경우. 목록에 없으므로 필터를 통과하고, 테스트는 그 경로를 조회하지 않는다.

spring.factories에 등록된 필터라는 특성상 fail-open이 기본값이라는 점을 javadoc이 인정하고 있으므로 은폐는 아니다. 기록하는 것은 보호의 범위다 — 목록이 열 개이고 확인되는 경로가 하나다.

닫는 방법 — 클래스패스의 AutoConfiguration.imports에서 graphql을 포함하는 후보를 읽어 하드코딩 목록과 대조하는 테스트를 추가하면, 새 항목이 추가되는 순간 목록을 갱신하도록 강제된다. 이 leaf는 이미 같은 형태의 고정을 두 번 쓰고 있다(theAutoConfigurationIsRegisteredInTheImportsMetadata, everyPlatformPropertyAppearsInTheGeneratedConfigurationMetadata).

4.2 — 그 외 결함 없음

35개 main 파일 중 조립·활성화·검증 경로가 전부 닫혀 있고, 각 결정에 그것이 막는 구체적 실패가 적혀 있다. 은퇴 키 거부, 프레임워크 플래그 기본값 기여, 마스터 게이트를 든 얇은 루트, off 계약의 두 절반과 그 각각의 테스트 — 이 sub-scope에서 미도달이거나 미검증인 장치는 없다.

5. Sub-scope 01 완료 조건

  • denominator 60 / 60 FULL_READ (probe가 file_count=60 확인)
  • §8.1~§8.4 수행 — 도달성(조립 진입점 전수) · off 계약 두 절반 비교 · 마스터 키 삼중 참조 · 하드코딩 목록 드리프트
  • 기록 1건, 결함 0건
  • 소스 미변경

Sub-scope 02 — schema + scalar + compat (46 files, main 37 + test 9)

내부 상태: COMPLETE — 46 / 46 FULL_READ · 근거 evidence/raw/208-inbound-graphql-schema-probes.txt

6. 무엇을 하는 코드인가

schema (19) — 스키마 거버넌스. 매핑 검사 게이트, @oneOf 규칙, 스칼라 매니페스트, SDL 조립과 계약 정체성.

이 패키지의 논증이 이 모듈에서 가장 촘촘하다:

  • GraphQlMappingInspectionGate — Spring의 스키마 매핑 검사를 보고서에서 게이트로 승격한다. 실행 시점도 근거가 있다: "Runs after all controller, scalar and type-resolver wiring is registered — inspecting earlier would report resolvers that simply had not been contributed yet." 그리고 GraphQlMappingPolicy가 그 승격 이유를 적는다 — "A silently unmapped field returns null at runtime instead of ..." — §3.1에서 본 HealthGraphqlController 사고의 형태다.
  • GraphQlMappingIssue — 스키마 좌표와 선언 리소스만 담고 그 외에는 아무것도 담지 않는다: "A mapping report runs at startup and lands in logs, so it must never carry request or credential content."
  • GraphQlScalarDefinitionUpload 스칼라를 생성자에서 거부한다: "the platform does not implement GraphQL multipart upload, and binary lifecycle belongs to the Fileserver capability, so an Upload scalar could only ever be a half-working promise."
  • GraphQlScalarManifest — 중복 스칼라 이름을 거부한다: "two declarations of one scalar mean two coercions and the wiring order would silently pick a winner."
  • GraphQlSchemaContract — 스키마 해시 하나로 호환성을 판정하지 않는다. SDL 바이트가 같아도 파괴적 변경 규칙·스칼라 강제 변환·디렉티브 의미가 바뀌었으면 외부 계약이 다르므로 네 부분 전부를 비교하고, sameSchemaBytes는 "available for cache keying but is explicitly not a compatibility verdict"로 분리한다.

scalar (7)BigDecimal(211) · Long(189) · Instant(117) · Uuid(109) · Date(105) 강제 변환과 GraphQlDecimalBounds. GraphQlScalarWiringConfigurer가 이들을 엮고, 자동설정이 그것을 7곳에서 참조한다.

compat (11) — 스키마 호환성 엔진. GraphQlSchemaComparator(791) · GraphQlChangeKind(285) · GraphQlDeprecationGate(91) · GraphQlCompatibilityPolicy · GraphQlRemovalDecision · GraphQlClientOwnerApproval.

7. Negative-space probes — sub-scope 02

7.1 (8.1) 도달성 — 파일 단위 배선 전수

autoconf 열은 autoconfigure 패키지 파일들에서의 참조 수, main_other는 자기 파일과 autoconfigure를 제외한 main 참조 파일 수:

타입 autoconf main_other test 판정
GraphQlScalarWiringConfigurer 7 0 2 배선됨
GraphQlScalarManifest 4 1 2 배선됨
GraphQlMappingInspectionGate 3 0 2 배선됨
GraphQlScalarDefinition 3 1 2 배선됨
GraphQlSchemaHash 3 2 2 §8.1
GraphQlSchemaAssembler 0 0 3 미배선
GraphQlSchemaContract 0 0 1 미배선
GraphQlOneOfSchemaGate 0 0 2 미배선
GraphQlOneOfInputValidator 0 0 1 미배선
스칼라 5종(BigDecimal·Date·Instant·Long·Uuid) 0 1~2 1~2 GraphQlScalarWiringConfigurer 경유로 배선됨
7.2 (8.2) 조건 형제 비교 — 스키마 해시의 생산자와 소비자

GraphQlSchemaHash의 유일한 생산 경로는 GraphQlSchemaAssemblyResult.schemaHash()(:94-95)이고, 그 결과 타입은 GraphQlSchemaAssembler.assemble(...)만 만든다. 둘 다 프로덕션 호출자가 없다(§7.1).

소비 쪽은 GraphQlPlatformActuatorEndpoint가 생성자로 받는다. 그 클래스의 저장소 전체 참조는:

autoconfigure/GraphQlPlatformActuatorEndpoint.java:18   (클래스 선언)
autoconfigure/GraphQlPlatformActuatorEndpoint.java:38   (생성자)
autoconfigure/GraphQlPolicyRequestPathTest.java:28      (javadoc 언급)
autoconfigure/GraphQlPlatformStartupValidatorTest.java:108  (테스트가 직접 생성)

GraphQlPlatformAutoConfiguration의 39개 @Bean 중 이것을 만드는 것이 없다. §8.1.

(이 파일은 autoconfigure 패키지에 있어 sub-scope 01의 분모에 포함된다. 스키마 해시 사슬의 소비 쪽이므로 여기서 함께 다룬다.)

7.3 (8.3) 중복 메커니즘 — @oneOf 검증

GraphQlOneOfPolicy가 규칙을 한 곳에 두는 이유를 적는다 — "The rules are stated here once so the schema gate and the runtime validator cannot disagree about what the directive means." 그 두 소비자가 모두 미배선이다(§7.1).

한편 이 leaf는 com.graphql-java:graphql-java:25.0을 쓰고, 그 버전은 @oneOf 입력 객체를 스키마 빌드와 실행 양쪽에서 자체 처리한다. 즉 런타임 동작은 라이브러리가 덮고, 플랫폼 계층만 비어 있다. §8.2.

src/main/resourcessrc/test/resources의 어떤 .graphqls에도 oneOf가 없으므로 현재 스키마에서는 어느 쪽도 실행될 일이 없다.

7.4 (8.4) 문서/구현 드리프트

GraphQlSchemaAssembler의 javadoc이 막겠다고 선언한 위험:

"Filesystem and classpath enumeration order is not stable across machines or packaging formats, and an unstable order would both move the schema hash and change which of two conflicting declarations 'wins' — so the order is imposed here rather than inherited from discovery."

프로덕션에서 SDL을 병합하는 것은 Spring GraphQL의 자체 리소스 탐색이고, 이 조립기는 실행되지 않는다. 즉 두 위험 모두 완화되지 않는다. §8.1.

compat 패키지는 성격이 다르다 — GraphQlSchemaComparator(791줄)와 GraphQlDeprecationGate는 릴리스 게이트에서 두 스키마를 비교하는 도구이고, advanced/codegen이 참조한다(main_other=1). 런타임 경로가 아니므로 미배선이 정상이다.

8. Sub-scope 02 findings

8.1 P2 — 스키마 조립·계약 정체성·해시 사슬이 통째로 미배선이고, 그것을 발행할 액추에이터 엔드포인트도 등록되지 않는다

사슬 전체가 끊겨 있다:

GraphQlSchemaAssembler.assemble(resources)      호출자 0
  └─> GraphQlSchemaAssemblyResult               생산자 0
        └─> .schemaHash() -> GraphQlSchemaHash  생산자 0
              └─> GraphQlPlatformActuatorEndpoint(properties, schemaHash, ...)   @Bean 0
                    └─> GraphQlPlatformConfigurationReport                        발행 경로 0
GraphQlSchemaContract(hash, breakingPolicyV, scalarManifestV, directiveManifestV) 생성자 호출 0

세 가지가 함께 사라진다:

  1. 결정적 병합 순서. SDL 조각의 정렬을 조립기가 강제하도록 설계돼 있고(§7.4), 실제로는 Spring GraphQL의 탐색 순서를 그대로 쓴다. 조각이 하나(skeleton.graphqls)뿐인 지금은 무해하지만, adopter가 자기 .graphqls를 추가하는 순간 — 그것이 이 leaf의 문서화된 확장 방식이다 — 충돌 선언의 승자와 스키마 해시가 패키징 방식에 따라 달라질 수 있다.
  2. 네 부분 계약 정체성. GraphQlSchemaContract가 "해시만으로는 호환성을 판정할 수 없다"는 판단을 타입으로 만들었는데, 그 타입을 만드는 코드가 없다. 호환성 판정이 필요한 곳(릴리스 게이트)은 compat의 비교기를 직접 쓴다.
  3. 운영 가시성. GraphQlPlatformActuatorEndpoint.report()가 배포된 스키마 해시 · 실행 프로파일 · 배포 모드 · 활성 능력 · 등록된 연산/페치 프로파일 수를 하나의 보고서로 낸다. 등록되지 않으므로 운영자가 "이 배포가 무엇을 켜고 있는가"를 물을 표면이 없다.

inbound-web §44.2와 같은 형태다 — 거기서는 WebPlatformStartupValidator(62줄)가 시작 시 실행되지 않았고, 여기서는 GraphQlPlatformActuatorEndpoint가 등록되지 않는다. 두 모듈 모두 fileserver 하위 트리(attestMapping()이 app-bootstrap에서 실제로 호출되는)와 대조된다.

권고GraphQlPlatformAutoConfiguration이 이미 39개 빈을 만들고 GraphQlSchemaHash를 세 곳에서 참조하므로 자리는 있다. GraphQlSource가 확정된 뒤 그 SDL로 GraphQlSchemaAssembler를 돌려 해시를 얻고, 그것으로 액추에이터 엔드포인트를 만든다. 그러면 세 가지가 함께 닫힌다.

8.2 P3 — @oneOf 게이트와 런타임 검증기가 미배선이고, "플랫폼이 강제한다"는 서술이 그것을 넘어선다

GraphQlOneOfPolicy의 클래스 javadoc은 "The September 2025 @oneOf input rules the platform enforces"로 시작한다. 강제하는 두 코드 — 시작 게이트와 런타임 검증기 — 는 프로덕션 호출자가 0이다.

노출은 없다. graphql-java 25.0이 @oneOf를 자체 처리하므로 런타임 거부는 라이브러리가 한다. 그리고 현재 스키마에 @oneOf 선언이 없다.

기록하는 것은 두 가지다: (a) 플랫폼 계층의 강제가 서술과 달리 존재하지 않는다, (b) GraphQlOneOfSchemaGate.verify(sdl)가 확인하는 것은 라이브러리가 확인하지 않는 부분(멤버가 전부 nullable이고 기본값이 없어야 한다는 선언 시점 규칙)이므로, adopter가 잘못된 @oneOf 입력 타입을 선언하면 시작 시점이 아니라 첫 요청에서 드러난다.

8.3 — compat·scalar 결함 없음

compat 11개 파일은 릴리스 도구이고 advanced/codegen과 테스트가 소비한다 — 런타임 미배선이 정상이다. scalar 7개는 GraphQlScalarWiringConfigurer(autoconf=7)를 통해 전부 배선된다.

9. Sub-scope 02 완료 조건

  • denominator 46 / 46 FULL_READ
  • §8.1~§8.4 수행 — 파일 단위 배선 전수, 해시 사슬 생산자·소비자 추적, @oneOf 중복 메커니즘, 조립 순서 드리프트
  • P2 1건, P3 1건
  • 소스 미변경

Sub-scope 03 — execution + context + runtime (60 files, main 48 + test 12)

내부 상태: COMPLETE — 60 / 60 FULL_READ · 근거 evidence/raw/209-inbound-graphql-execution-probes.txt

10. 무엇을 하는 코드인가

runtime (19+1) 이 실제 실행 사슬이고, 이 모듈에서 자동설정 참조가 가장 조밀한 곳이다. GraphQlPlatformWebInterceptor(167, autoconf=3) · GraphQlExecutionChain(82, 5) · GraphQlPlatformInstrumentation(103, 3) · GraphQlPreparsedDocumentAdapter(117, 4) · GraphQlWireErrorMapper(124, 4) · GraphQlRequestBodyLimitFilter(213, 4) · GraphQlPrincipalResolver(35, 4) · GraphQlBatchLoaderRegistrar(128, 4) · GraphQlDataFetcherExceptionResolver(70, 3) · GraphQlRequestObservationConventionAdapter(176, 3) — 전부 배선돼 있다.

context (6) 은 요청 정체성이다. GraphQlRequestContext(76)가 main 22개 파일에서 참조되는 이 모듈의 중심 값이고, ActorRef · TenantContext · GraphQlDeadline · GraphQlIdentityFingerprinter(150)를 담는다.

GraphQlRequestContext.withDeadline조이기만 한다:

GraphQlDeadline effective = tightened.value().isBefore(deadline.value()) ? tightened : deadline;

들어온 값이 기존보다 늦으면 무시된다 — 하위 단계가 예산을 늘릴 수 없다.

execution (22) 은 실행 정책 어휘다. 프로파일 · 파이프라인 · 사전 파싱 캐시 · 취소 · 타임아웃 정책 · 리졸버 예산과 카탈로그 · 연산 이름 정책.

BoundedPreparsedDocumentProvider(194, autoconf=4)와 GraphQlPreparsedCachePolicy(4) · GraphQlPreparsedCacheMetrics(2)가 배선돼 있다 — 파싱 캐시는 GraphQL에서 무한 증가하기 쉬운 지점이고 그 상한이 실제로 걸린다.

11. Negative-space probes — sub-scope 03

11.1 (8.1) 도달성 — 배선 전수에서 남는 셋

48개 main 파일 중 autoconf=0이면서 자기 패키지 밖 main 참조도 0인 것은 셋이다:

타입 LOC test 무엇을 하는가
GraphQlDeadlinePropagator 71 1 설계 §10의 5계층 예산 파생
GraphQlOperationNameInterceptor 55 1 연산 이름 정책 적용 + 컨텍스트에 연산 정체성 고정
GraphQlResolverCatalog 61 1 리졸버 등록부(프로파일 검사의 전제)
11.2 (8.2) 조건 형제 비교 — 연산 정체성을 정하는 두 구현

GraphQlOperationNameInterceptor.apply(...)(미배선)와 runtime/GraphQlOperationSelectionHandler(autoconf=2, 배선됨)가 같은 일을 한다. 배선된 쪽이 더 많이 한다:

// GraphQlOperationSelectionHandler  (:53, :65, :68, :75)
if (operations.isEmpty())                          throw new GraphQlAnonymousOperationException("the document declares no operation");
if (requestedName 지정 && 일치 없음)                 throw ... ("the document declares no operation named " + requestedName);
if (이름 없음 && operations.size() > 1)             throw ... ("operationName is required when the document declares N operations");
if (policy.namedOperationRequired() && name==null) throw ... ("this client profile requires a named operation");
...
return context.withSelection(selected, selection, context.requestContext().withOperationId(operationId(selected)));

익명 연산 거부 · 다중 연산 시 operationName 요구 · 연산 정체성 정규화가 전부 배선된 경로에 있다. 정규화 규칙에는 근거도 붙어 있다 — "any name that cannot survive normalisation becomes the anonymous identity rather than being rejected — a naming convention is not a reason to refuse an otherwise valid request."

따라서 미배선 인터셉터는 누락이 아니라 중복이다. 다만 그것이 쓰는 GraphQlOperationNamePolicy(85줄, 참조자 = 인터셉터와 자기 자신뿐)도 함께 미배선이고, 배선된 핸들러는 다른 정책 객체를 쓴다. §12.2.

11.3 (8.3) 중복 메커니즘 — 예산 계층

설계 §10이 다섯 계층을 정의하고 GraphQlDeadlinePropagator가 그 파생을 담는다. 실제 강제 상태:

계층 파생 지점 배선
전송 핸드셰이크 (이 sub-scope 밖)
요청 GraphQlPlatformWebInterceptor:135GraphQlDeadline.after(policy.maxExecutionTime(), clock)
리졸버 GraphQlDeadlinePropagator.resolverBudget(...) 아니오
DataLoader 배치 GraphQlDeadlinePropagator.dataLoaderBatchTimeout(...) 아니오
다운스트림(DB/HTTP) GraphQlDeadlinePropagator.downstreamDeadline(...) 아니오
구독 연결 GraphQlDeadlinePropagator.subscriptionDeadline(...) 아니오

GraphQlTimeoutPolicyGraphQlResolverBudget의 main 참조자를 전수하면 전부 execution 패키지 안(그리고 미배선 클러스터 안)이다:

GraphQlTimeoutPolicy   <- GraphQlRequestCancelledException, GraphQlDeadlinePropagator, GraphQlResolverBudget
GraphQlResolverBudget  <- GraphQlResolverDescriptor, GraphQlResolverCatalog, GraphQlDeadlinePropagator, GraphQlExecutionProfileValidator

§12.1.

11.4 (8.4) 문서/구현 드리프트 — 취소 경로

GraphQlCancellation(93)은 cost/GraphQlRuntimeBudgetTracker · advanced/incremental · advanced/subscription 세 곳에서 쓰인다. 요청 데드라인이 실제로 실행을 끊는 경로가 존재한다는 뜻이고, GraphQlRequestContext.withDeadline의 단조 조이기와 함께 요청 계층은 완결돼 있다.

12. Sub-scope 03 findings

12.1 P2 — 5계층 예산 모델에서 요청 계층만 강제되고, 나머지 파생이 전부 미배선이다

GraphQlDeadlinePropagator의 javadoc이 계층 분리의 이유를 정확히 적는다:

"The layers are separate because they mean different things and expire at different points: a transport handshake, the request execution, one resolver, one DataLoader batch, and — for a subscription — the connection itself. The last one is deliberately not derived from the request budget: a subscription is a long-lived stream, and applying a five-second request timeout to it would terminate every subscription five seconds after it started."

그 파생을 수행하는 메서드 다섯 개 전부 프로덕션 호출자가 0이다(§11.1·§11.3).

실제로 강제되는 것과 아닌 것:

  • 요청 전체 데드라인 — GraphQlPlatformWebInterceptor가 만들고 GraphQlCancellation이 끊는다. 작동한다.
  • 개별 리졸버가 남은 요청 예산으로 잘리는 것 — 없다.
  • DataLoader 배치 타임아웃이 남은 요청 예산으로 잘리는 것 — 없다.
  • DB/HTTP 다운스트림 호출에 남은 예산이 전달되는 것 — 없다.

실패 시나리오 — 요청 예산이 5초이고 리졸버 하나가 다운스트림 HTTP를 부른다. 그 호출에 전달되는 데드라인은 아웃바운드 어댑터 자신의 기본값(예: 10초)이고, 남은 요청 예산이 1초라는 사실은 전달되지 않는다. 요청은 5초에 취소되지만 다운스트림 호출은 계속 진행되어 연결과 스레드를 4초 더 붙잡는다. GraphQlDeadlinePropagator.downstreamDeadline이 정확히 그 clamping을 위해 존재한다.

DataLoader 쪽은 더 직접적이다 — dataloader/GraphQlBatchContextGraphQlDeadline을 레코드 컴포넌트로 갖지만, 그 값을 남은 요청 예산으로 잘라 넣는 코드가 dataLoaderBatchTimeout이고 호출자가 없다.

권고GraphQlDeadlinePropagator를 빈으로 등록하고 세 지점에 연결한다: GraphQlBatchLoaderRegistrar(autoconf=4)가 배치 타임아웃을, 리졸버 실행 경로가 resolverBudget을, 그리고 아웃바운드 포트 호출 지점이 downstreamDeadline을 쓰게 한다. 구독 계층은 §8의 advanced/subscription에서 별도로 확인한다.

12.2 P3 — 연산 이름 정책의 두 구현 중 하나만 배선되고, 미배선 쪽만 GraphQlOperationNamePolicy를 쓴다

§11.2. 익명 연산 거부는 배선된 GraphQlOperationSelectionHandler가 수행하므로 강제 자체는 존재한다. 기록하는 것은 정책 객체의 이원화다 — GraphQlOperationNamePolicy(85줄)의 참조자가 미배선 인터셉터와 자기 자신뿐이고, 배선된 핸들러는 별개의 policy를 쓴다. 두 정책이 "이름 있는 연산을 요구하는가"에 대해 서로 다른 답을 낼 수 있는 구조이며, 지금은 한쪽만 답한다.

12.3 P3/기록 — GraphQlResolverCatalog가 비어 있어 실행 프로파일 검사가 대상을 갖지 않는다

GraphQlResolverCatalog의 javadoc: "Registration is what makes the profile check possible: an unregistered resolver cannot be checked against the runtime profile, so looking one up fails rather than assuming it is safe."

등록하는 코드가 없다. GraphQlExecutionProfileValidator(autoconf=0, main_other=1)가 GraphQlResolverDescriptor/GraphQlResolverBudget을 소비하지만 그 자신도 미배선이다. 즉 "리졸버가 실행 프로파일에 맞는가"를 판정하는 층 전체가 비어 있다.

현재 이 leaf에 리졸버가 하나(HealthGraphqlController._health)뿐이므로 노출은 없다. adopter가 리졸버를 기여하는 시점에 이 검사가 없다는 사실이 드러난다.

13. Sub-scope 03 완료 조건

  • denominator 60 / 60 FULL_READ
  • §8.1~§8.4 수행 — 48개 main 파일 배선 전수, 연산 정체성 두 구현 비교, 예산 5계층 강제 상태 확인, 취소 경로 확인
  • P2 1건, P3 2건
  • 소스 미변경

Sub-scope 04 — cost + policy + security (57 files, main 45 + test 12)

내부 상태: COMPLETE — 57 / 57 FULL_READ · 근거 evidence/raw/210-inbound-graphql-cost-security-probes.txt

14. 무엇을 하는 코드인가

GraphQL에서 비용 제어와 권한은 REST보다 어렵다 — 클라이언트가 쿼리 모양을 정하므로 "한 요청이 얼마나 비싼가"를 실행 전에 판정해야 한다. 이 세 패키지가 그 판정을 담는다.

cost (21) 은 네 계층으로 나뉜다:

계층 타입 배선
파서 한계(토큰 수 · 규칙 깊이) GraphQlParserLimits · GraphQlParserOptionsFactory · GraphQlParserLimitPolicy §16.1
구조 한계(깊이 · 폭 · 별칭 · 프래그먼트) GraphQlStructuralLimitPolicy(autoconf=4) · GraphQlStructuralLimits(2) · GraphQlDocumentShapeAnalyzer(4, 323줄)
복잡도 점수 GraphQlComplexityCalculator(4) · GraphQlCostCatalog(4) · GraphQlDocumentComplexityScorer(295) · GraphQlFieldCostDescriptor · GraphQlResolverWeight
런타임 예산(응답 바이트 · 노드 수) GraphQlRuntimeBudgetTracker(95) · GraphQlResponseByteLimiter · GraphQlResponseNodeCounter 간접

policy (9) 는 클라이언트 프로파일별 정책이다. GraphQlClientPolicy(125)가 이 모듈에서 가장 널리 쓰이는 정책 값이고 자동설정에서 12곳, 다른 main 12파일에서 참조된다.

security (15) 는 인증 컨텍스트 · 권한 · 테넌트 격리 · 컨텍스트 전파다.

GraphQlContextPropagator(88)의 논증이 특히 정확하다:

"GraphQL execution hops threads constantly — an async data fetcher, a DataLoader dispatch, a scheduler bridge — and a context held only in a thread local silently disappears at the first hop. That is not a lost tag: it is a batch load running with no tenant."

그리고 네 개 진입점(call · wrap(Callable) · wrap(Runnable) · restore) 전부 finally에서 이전 바인딩을 복원한다 — 풀 스레드에 남는 상태가 없다.

GraphQlClientProfileResolver(57)는 프로파일을 어디서 읽지 않는지를 먼저 말한다:

"Never from a variable, an extension or a header the caller controls: the profile selects the cost, page-size and introspection limits, so a caller that could name its own profile could grant itself the admin budget."

15. Negative-space probes — sub-scope 04

15.1 (8.1) 도달성 — 배선 전수에서 남는 여섯

45개 main 파일 중 autoconf=0이면서 자기 패키지 밖 main 참조도 0인 것:

타입 LOC 무엇을 하는가
GraphQlParserOptionsFactory 43 graphql-java ParserOptions에 플랫폼 한계를 설치
GraphQlParserLimitPolicy 61 파서 한계 정책 보유
GraphQlClientPolicyManifest 92 프로파일 → 정책 매니페스트
GraphQlOperationCatalog 72 등록된 연산 목록
GraphQlPolicyViolation 31 정책 위반 값
GraphQlClientProfileResolver 57 검증된 자격 클레임 → 프로파일
GraphQlContextCleanup 62 요청 종료 시 정리 스코프
15.2 (8.2) 조건 형제 비교 — 클라이언트 정책이 어떻게 정해지는가

설계는 매니페스트 조회를 말한다:

GraphQlClientPolicyManifest — "The design keeps benchmarked limits in an environment manifest rather than in application code, so this is the one place a profile is resolved from. An unknown profile is a startup or request failure rather than a silent fallback to a permissive default."

자동설정은 단일 빈을 만든다:

// GraphQlPlatformAutoConfiguration:302-303
@Bean public GraphQlClientPolicy graphQlClientPolicy(GraphQlPlatformSettings properties) {
  return GraphQlClientPolicy.defaults(...);
}

그리고 그 하나가 여덟 개 빈(:123 · :237 · :380 · :389 · :397 · :453 · :463 …)에 주입된다. 매니페스트는 만들어지지 않는다. §16.2.

프로파일 자체는 신뢰된 경로에서 온다GraphQlAuthenticationContextFactory:59principal.clientProfile()을 쓰고(검증된 principal), 미인증 호출자에는 GraphQlPlatformWebInterceptoranonymousProfile이 붙는다. 즉 GraphQlClientProfileResolver가 막으려는 노출(호출자가 자기 프로파일을 지정)은 배선된 경로에서도 발생하지 않는다. 그 타입은 중복이다.

15.3 (8.3) 중복 메커니즘 — 컨텍스트 전파와 정리

GraphQlContextPropagator의 javadoc은 "Every hop goes through here"라고 하지만 프로덕션 사용처는 하나다 — runtime/GraphQlBlockingBridge:80. 나머지 홉에서 컨텍스트를 나르는 것은 graphql-java 자신의 GraphQLContext이고, 런타임이 그것을 읽는다(GraphQlPreparsedDocumentAdapter:101, GraphQlRequestObservationConventionAdapter:128, GraphQlRequestContext.CONTEXT_KEY).

누수는 없다. ThreadLocal을 쓰는 세 진입점 전부 finally에서 복원하고, 테스트가 그것을 확인한다(GraphQlTenantIsolationPolicyTest:72,75가 실행 후 바인딩이 비어 있음을 단언).

GraphQlContextCleanup(62)은 그 위에 얹는 일반 정리 스코프이고 프로덕션 등록이 없다. 전파기가 스스로 정리하므로 부재가 노출을 만들지 않는다.

15.4 (8.4) 문서/구현 드리프트 — 파서 한계

GraphQlParserLimitPolicy의 javadoc이 이 한계가 어디서 강제되는지 적는다 — "... by the parser itself through GraphQlParserOptionsFactory". 그 팩토리의 두 메서드:

public static ParserOptions create(GraphQlParserLimits limits)
public static ParserOptions installOperationDefaults(GraphQlParserLimits limits)

프로덕션 호출자가 0이다. GraphQlParserLimits.from(GraphQlClientPolicy policy)(설정 → 한계 변환)도 마찬가지다. §16.1.

16. Sub-scope 04 findings

16.1 P2 — 설정으로 정한 파서 한계가 graphql-java에 설치되지 않는다

파서 계층은 GraphQL DoS 방어의 첫 번째 관문이다 — 복잡도 계산도 구조 분석도 문서를 파싱한 뒤에 일어나므로, 파싱 자체를 폭발시키는 문서는 그 앞에서 막아야 한다.

이 leaf는 그 한계를 값으로 갖고(GraphQlParserLimits, 클라이언트 정책에서 파생), 설치 함수를 갖는다(GraphQlParserOptionsFactory.installOperationDefaults). 호출하는 코드가 없다.

installOperationDefaults라는 이름이 가리키듯 graphql-java의 파서 옵션은 정적 전역(ParserOptions.setDefaultOperationParserOptions)이고, 시작 시 한 번 설치하지 않으면 라이브러리 기본값이 유지된다.

실패 시나리오 — 운영자가 backend.graphql.limits.*로 파서 한계를 조인다. 그 값은 GraphQlPlatformSettingsGraphQlClientPolicy까지 도달하지만 GraphQlParserLimits.from(...)을 부르는 코드가 없어 파서에 닿지 않는다. 실제로 적용되는 것은 graphql-java 25.0의 기본값이다. 설정은 받아들여지고 검증되며 효과가 없다.

노출의 크기는 라이브러리 기본값이 정한다. graphql-java 25.0은 토큰 수·공백 토큰 수·규칙 깊이에 자체 기본 상한을 두므로 무제한은 아니다. 그리고 구조 한계(GraphQlDocumentShapeAnalyzer, autoconf=4)와 복잡도 계산(autoconf=4)은 배선돼 있어 파싱 이후 계층은 작동한다. 그래서 P1이 아니라 P2다 — 침묵하는 설정 표면이자 방어 계층 하나의 부재다.

권고GraphQlPlatformAutoConfiguration에 시작 시 GraphQlParserOptionsFactory.installOperationDefaults(GraphQlParserLimits.from(clientPolicy))를 한 번 호출하는 초기화 지점을 둔다. 정적 전역이므로 @Bean 메서드보다 InitializingBean/SmartInitializingSingleton이 적절하다.

16.2 P2 — 프로파일별 정책 매니페스트가 미배선이라, 자격에서 해석된 프로파일이 아무 예산도 선택하지 않는다

§15.2. 클라이언트 프로파일은 검증된 principal에서 정확히 해석되고 요청 컨텍스트에 실린다. 그리고 그 값이 선택하는 것은 캐시 키와 지표 태그뿐이다 — 정책은 프로파일과 무관하게 단일 빈이다.

설계가 이 구조를 명시적으로 거부한다: "The design keeps benchmarked limits in an environment manifest rather than in application code." 지금은 코드 안의 GraphQlClientPolicy.defaults(properties) 하나다.

실패 시나리오 — 배포가 내부 배치 클라이언트에는 큰 복잡도 예산을, 공개 모바일 클라이언트에는 작은 예산을 주려 한다. 두 프로파일이 자격에서 정확히 구분되고, 두 요청 모두 같은 GraphQlClientPolicy로 평가된다. 프로파일을 나눈 목적이 달성되지 않으며, 그 사실은 어떤 오류로도 드러나지 않는다 — GraphQlClientPolicyManifest가 약속한 "An unknown profile is a startup or request failure rather than a silent fallback to a permissive default"는 조회가 일어나지 않으므로 성립할 기회가 없다.

권고GraphQlClientPolicy 단일 빈을 GraphQlClientPolicyManifest 빈으로 바꾸고, 정책을 요구하는 여덟 지점이 요청 컨텍스트의 프로파일로 조회하게 한다. 매니페스트는 중복 프로파일을 생성자에서 거부하므로 설정 오류가 부팅에서 드러난다.

16.3 P3/기록 — 중복이거나 미사용인 네 타입
타입 판정
GraphQlClientProfileResolver (57) 중복 — 배선된 경로(GraphQlAuthenticationContextFactory:59)가 검증된 principal에서 프로파일을 가져오므로 보안 성질은 유지된다
GraphQlContextCleanup (62) 미사용 — 전파기가 finally로 스스로 복원하므로 부재가 누수를 만들지 않는다
GraphQlOperationCatalog (72) · GraphQlPolicyViolation (31) 미사용 — 연산 등록부는 §12.3의 GraphQlResolverCatalog와 같은 형태로 비어 있다
16.4 P3/기록 — GraphQlContextPropagator의 "every hop" 서술이 실제 사용처와 다르다

§15.3. 프로덕션 사용처가 GraphQlBlockingBridge 하나다. 다른 홉은 graphql-java의 GraphQLContext가 나르며 그것이 올바른 전송 수단이다 — 결함이 아니라 서술의 범위 문제다. 다만 "batch load running with no tenant"를 막는 것이 GraphQLContext라는 사실이 코드 어디에도 적혀 있지 않아, DataLoader 경로에서 테넌트가 어떻게 유지되는지는 GraphQlBatchLoaderRegistrar(autoconf=4)를 읽어야만 알 수 있다.

17. Sub-scope 04 완료 조건

  • denominator 57 / 57 FULL_READ
  • §8.1~§8.4 수행 — 45개 main 파일 배선 전수, 정책 결정 경로 비교, 컨텍스트 전파/정리 중복, 파서 한계 드리프트
  • P2 2건, 기록 2건
  • 소스 미변경

Sub-scope 05 — http + error + observation (48 files, main 38 + test 10)

내부 상태: COMPLETE — 48 / 48 FULL_READ · 근거 evidence/raw/211-inbound-graphql-http-probes.txt

18. 무엇을 하는 코드인가

observation (9) 은 이 sub-scope에서 가장 잘 배선된 부분이다. GraphQlSensitiveAttributeFilter(autoconf=6) · GraphQlRequestObservationConvention(4, 180줄) · GraphQlMetricCardinalityPolicy(4) · GraphQlOperationNameCardinality(4) · GraphQlDataLoaderObservationConvention(3) · GraphQlResolverObservationConvention(3) — 지표 태그의 카디널리티와 민감 속성 필터가 실제로 적용된다.

error (10) 은 두 갈래다. GraphQlExceptionResolver(autoconf=4)가 리졸버 예외를 배선된 경로에서 처리하고, GraphQlWireError(102)와 GraphQlErrorContext(40)가 각각 main 12개 파일에서 참조되는 공용 어휘다. GraphQlInternalErrorMasker(75)가 내부 예외를 마스킹한다.

http (19) 은 HTTP 전송 계약이다 — Accept 협상(GraphQlAcceptHeader 151), 미디어 타입, 요청 봉투 검증, 응답 팩토리, 상태 매퍼, 크기 한계. 설계 §9.2 · §18의 사전 파싱 한계가 여기 있고, 그 순서에 대한 논증이 정확하다:

GraphQlRequestEnvelopeValidator — "Everything here runs before the GraphQL parser sees the document. That ordering is the point: a parser has to allocate proportionally to its input, so a size limit applied [later is too late]."

GraphQlHttpResponseFactory — "Having one factory is what keeps the 4xx/200 split from drifting: every transport builds its response here, so a new outcome cannot be introduced with an ad-hoc status at one call site."

19. Negative-space probes — sub-scope 05

19.1 (8.1) 도달성 — HTTP 엔드포인트를 누가 소유하는가
$ grep -rn 'GraphQlHttpHandler|WebGraphQlHandler|RouterFunction|@PostMapping' src/main --include=*.java
(결과 없음)

이 leaf에는 HTTP 엔드포인트를 발행하는 코드가 없다. /graphql은 Spring GraphQL 자신의 자동설정이 발행하고, 그것이 §3.2의 GraphQlOffAutoConfigurationImportFilter가 존재하는 이유다 — "the framework serves it anyway."

따라서 http 패키지의 전송 기계는 소비자를 가질 수 없다. GraphQlHttpOutcome(main_other=8)의 참조자를 전수하면 전부 http 패키지 내부와 error/GraphQlRequestErrorMapper 하나다:

http/GraphQlHttpResponse · GraphQlHttpContractException · GraphQlRequestFormatException ·
GraphQlRequestTooLargeException · GraphQlHttpProfile · GraphQlHttpResponseFactory ·
GraphQlHttpStatusMapper · (자기 자신)     +     error/GraphQlRequestErrorMapper

GraphQlRequestErrorMapper 자신도 미배선이므로, 이 아홉 개는 닫힌 섬이다. §20.1.

19.2 (8.2) 조건 형제 비교 — 사전 파싱 한계의 두 구현
배선 무엇을 하는가
runtime/GraphQlRequestBodyLimitFilter (213) autoconf=4 요청 본문 바이트 상한
runtime/GraphQlJsonStructurePolicy 소비 (100) autoconf=4 JSON 구조 정책
http/GraphQlRequestEnvelopeValidator (152) 0 설계 §9.2·§18의 사전 파싱 한계 전체
http/GraphQlRequestSize (88) 0 (main_other=3, 전부 http 내부) 크기 표현

바이트 상한과 JSON 구조는 배선된 두 컴포넌트가 덮는다. 봉투 검증기가 추가로 무엇을 확인하는지는 그 파일에만 있고, 그 차이는 프로덕션에서 실행되지 않는다.

19.3 (8.3) 중복 메커니즘 — 실행 전 실패의 매퍼

GraphQlRequestErrorMapper의 javadoc이 자기 존재 이유를 적는다:

"A separate mapper from the resolver one, because a DataFetcherExceptionResolver never sees these: parse and validation failures happen before any data fetcher is invoked."

진단이 정확하고, 배선된 것은 리졸버 쪽(GraphQlExceptionResolver, autoconf=4)뿐이다. 파싱·검증 실패의 와이어 형식을 이 플랫폼이 정하지 않는다는 뜻이다 — 다만 runtime/GraphQlWireErrorMapper(autoconf=4)와 runtime/GraphQlPlatformRejectionMapper(main_other=3)가 배선돼 있어 플랫폼이 거부하는 실패(익명 연산, 복잡도 초과 등)는 안정 코드로 매핑된다. 덮이지 않는 것은 graphql-java 자신이 만드는 구문/검증 오류다.

19.4 (8.4) 문서/구현 드리프트 — 보고되는 HTTP 프로파일

GraphQlPlatformConfigurationReport(§8.1)가 GraphQlHttpProfile.V1.name()을 배포 상태의 일부로 보고한다. GraphQlHttpProfile은 autoconf=2로 참조되지만, 그 프로파일이 규정하는 전송 동작(상태 매핑 · Accept 협상 · 응답 형태)을 수행하는 코드는 미배선이다(§19.1). 그리고 그 보고서를 발행할 액추에이터 엔드포인트 자체도 등록되지 않는다(§8.1).

20. Sub-scope 05 findings

20.1 P2 — http/가 등급표에서 wired로 선언돼 있으나 그 등급의 정의를 만족하지 않는다

(이 발견은 sub-scope 07에서 CLAUDE.md의 capability 등급표를 읽은 뒤 이 절로 되돌아와 다시 쓴 것이다. 등급표의 존재가 이 sub-scope의 판정을 바꾼다 — §24 참조.)

CLAUDE.md가 등급을 정의한다:

등급 의미
modelled 정책·계약 객체가 있고 단위 테스트가 있다. 요청 경로에는 없다.
wired Spring 실행 경로에 연결돼 있고, 실제 endpoint 테스트가 그 사실을 증명한다.

그리고 규칙을 명시한다 — "현재 등급보다 높게 표현하지 않는다."

http/ 행은 이렇다:

| 요청 크기/Accept 협상 (`http/`) | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` |

두 증거 테스트 모두 endpoint 테스트가 아니다. @SpringBootTestWebEnvironmentMockMvcApplicationContextRunner도 없고, 대상 타입을 직접 생성해 호출하는 순수 단위 테스트다:

// GraphQlRequestBoundsTest:37-38
GraphQlRequestEnvelopeValidator validator = GraphQlRequestEnvelopeValidator.maxVariablesBytes(8);

// GraphQlAcceptNegotiationTest:102
var entries = GraphQlAcceptHeader.parse("*/*;q=0.5, application/json;q=0.5, text/html;q=0.9");

그리고 배선 전수가 그것과 일치한다 — GraphQlRequestEnvelopeValidator(152줄) autoconf=0 · main_other=0, GraphQlAcceptHeader(151줄) autoconf=0 · main_other=1.

행의 두 항목을 나누어 보면:

항목 실제 상태
요청 크기 배선됨 — 단 http/GraphQlRequestEnvelopeValidator가 아니라 runtime/GraphQlRequestBodyLimitFilter(213줄, autoconf=4)가 한다. 행이 인용한 테스트는 배선되지 않은 쪽을 시험한다
Accept 협상 배선 안 됨GraphQlAcceptHeader를 부르는 프로덕션 코드가 없다. 협상은 Spring GraphQL이 한다

즉 이 행은 등급표의 자기 규칙을 어기는 유일한 행이다. 다른 여섯 개 wired 행은 증거로 runtime/GraphQlPlatformExecutionPathTest(random-port) · GraphQlBatchLoaderRegistrationTest(실제 graphql-java 실행) · GraphQlObservationWiringTest(프레임워크가 실제로 해석)를 든다 — 전부 정의를 만족한다.

왜 이것이 중요한가 — 이 등급표가 이 모듈의 주된 정직성 장치이고, 그것이 §24에서 확인하듯 실제로 작동한다(일곱 개 능력을 modelled로 스스로 강등하고, 그중 둘은 전용 테스트로 고정한다). 그 장치의 한 행이 틀리면, 등급표를 읽고 신뢰하는 사람이 정확히 그 행에서 틀린 결론을 얻는다.

20.1b 그 결과 — HTTP 전송 계약 계층이 미배선이고 실제 전송은 프레임워크가 정한다

http 패키지 19개 파일 중 자동설정이 값으로 소비하는 둘(GraphQlHttpProfile autoconf=2, GraphQlJsonStructurePolicy autoconf=4)을 빼면, 나머지는 실행되지 않는다:

미배선 LOC 무엇을 결정하기로 되어 있었나
GraphQlAcceptHeader 151 Accept 협상(application/jsonapplication/graphql-response+json)
GraphQlRequestEnvelopeValidator 152 사전 파싱 한계(설계 §9.2·§18)
GraphQlHttpResponseFactory 84 4xx/200 분리 — "every transport builds its response here"
GraphQlMediaTypes 100 허용 미디어 타입
GraphQlRequestSize · GraphQlHttpStatusMapper · GraphQlHttpResponse · GraphQlHttpResponsePolicy · GraphQlHttpRequestEnvelope · GraphQlHttpOutcome · GraphQlHttpExecutor · GraphQlExecutionOutcome · GraphQlExtensionsPolicy · GraphQlJsonValues 470 봉투·결과·확장 정책

GraphQL-over-HTTP에서 상태 코드 규칙은 미디어 타입에 달려 있다 — application/json은 실행 오류에도 200을, application/graphql-response+json은 실제 상태를 쓴다. 그 규칙을 GraphQlHttpStatusMapperGraphQlAcceptHeader가 담고 있고, 실제로 응답을 만드는 것은 Spring GraphQL이다.

노출이 아니라 통제권의 문제다. Spring GraphQL 자신이 GraphQL-over-HTTP 스펙을 구현하므로 동작은 합리적이다. 잃는 것은 (a) 이 플랫폼이 선언한 프로파일(V1)이 실제 동작과 일치한다는 보장, (b) 사전 파싱 한계 중 봉투 검증기에만 있는 부분, (c) "새 결과 종류가 임의 상태를 갖고 한 호출 지점에 생기는 것"을 막겠다는 단일 팩토리의 목적.

실패 시나리오 — 운영자가 GraphQlPlatformConfigurationReport(§8.1을 고쳐 발행하게 된 뒤)에서 httpProfile=V1을 읽고 그 프로파일 문서대로 클라이언트를 작성한다. 실제 응답 상태와 미디어 타입은 Spring GraphQL이 정하며, 두 문서가 다른 지점에서 클라이언트가 깨진다.

권고 — 둘 중 하나다. (a) 프레임워크 전송을 정본으로 인정하고 http 패키지에서 전송 기계를 제거한 뒤 GraphQlHttpProfile을 프레임워크 동작의 서술로 좁힌다. (b) WebGraphQlInterceptor(GraphQlPlatformWebInterceptor가 이미 그 자리에 있다)에서 봉투 검증과 응답 정책을 적용해 프로파일을 실제로 강제한다. 지금은 선언과 실행이 분리돼 있다.

20.2 P3 — 파싱·검증 실패에 플랫폼 매퍼가 없다

§19.3. GraphQlRequestErrorMapper(57)가 그 목적으로 존재하고 미배선이다. 배선된 두 매퍼(GraphQlExceptionResolver · GraphQlWireErrorMapper)는 각각 리졸버 예외와 플랫폼 거부를 덮는다.

결과적으로 구문 오류나 검증 실패의 응답에는 이 플랫폼의 안정 code/category 확장이 붙지 않고 graphql-java의 기본 형식이 나간다. 클라이언트가 오류 코드로 분기한다면 그 분기가 파싱 오류에서만 빗나간다.

20.3 P3/기록 — 구독 오류 리졸버와 프로파일러 접근 정책이 미배선이다

GraphQlSubscriptionExceptionResolver(62)는 "the response has already been committed: there is no data to make partial and no status left to change"인 경우를 다룬다 — 구독은 Advanced이므로 §8에서 그 활성화와 함께 확인한다.

GraphQlProfilerAccessPolicy(57)는 프로파일러를 "Never as a general response extension"으로 제한한다 — 프로파일러 자체를 켜는 코드가 없으므로 현재 노출은 없다.

GraphQlNullabilityContract(67)는 필드별 실패 동작 선언으로, 스키마가 하나뿐인 현재는 대상이 없다.

21. Sub-scope 05 완료 조건

  • denominator 48 / 48 FULL_READ
  • §8.1~§8.4 수행 — HTTP 엔드포인트 소유자 확인, 사전 파싱 한계 두 구현 비교, 실행 전 실패 매퍼 중복, 보고되는 프로파일 드리프트
  • P2 1건, P3 2건
  • 소스 미변경

Sub-scope 06 — dataloader + fetch + pagination + mutation (69 files, main 58 + test 11)

내부 상태: COMPLETE — 69 / 69 FULL_READ · 근거 evidence/raw/212-inbound-graphql-data-probes.txt

22. 무엇을 하는 코드인가

dataloader (17) — N+1 제거. GraphQlBatchExecutor(121) · GraphQlBatchResultMapper(134) · GraphQlBatchChunker · GraphQlBatchPolicyRegistry(autoconf=4) · GraphQlDataLoaderFactory(autoconf=4). runtime/GraphQlBatchLoaderRegistrar(autoconf=4)가 Spring의 BatchLoaderRegistry에 붙인다. 등급표가 wired로 선언하고 증거로 GraphQlBatchLoaderRegistrationTest(실제 graphql-java 실행 + 50 parent → 3 downstream 호출)를 든다.

pagination (17) — Relay 커서 연결. HmacGraphQlCursorCodec(181) · GraphQlCursorKeyRing(81) · GraphQlCursorPayload(123) · GraphQlCursorFraming(67) · GraphQlConnectionAssembler(108).

fetch (10) — 선택 집합 모양에 따른 페치 프로파일 분류.

mutation (14) — 뮤테이션 계약과 멱등성. GraphQlMutationIdempotencyInterceptor(77) · GraphQlMutationFingerprint(49) · GraphQlCanonicalInput(87) · GraphQlMutationContractValidator(92).

23. Negative-space probes — sub-scope 06

23.1 (8.1) 도달성 — 네 패키지의 배선 상태

58개 main 파일 중 자동설정이 참조하는 것은 이다 — GraphQlBatchPolicyRegistry(4) · GraphQlDataLoaderFactory(4). 나머지 56개는 autoconf=0이다.

dataloaderruntime/GraphQlBatchLoaderRegistrar를 통해 도달하므로 배선돼 있다. fetch·pagination·mutation 41개 파일은 어떤 배선 경로에도 없다.

23.2 (8.2) 조건 형제 비교 — 커서 서명 키의 두 소비처

backend.graphql.cursor.key-ids를 읽는 프로덕션 코드는 둘이다:

autoconfigure/GraphQlPlatformStartupValidator.java:42-43
    if (properties.production() && properties.cursor().keyIds().isEmpty()) {
      problems.add("a cursor signing key is required; unsigned cursors are client-editable");

autoconfigure/GraphQlPlatformActuatorEndpoint.java   (보고서에 포함)

서명하는 코드는 없다. HmacGraphQlCursorCodecGraphQlCursorKeyRing은 autoconf=0 · main_other=0이다.

23.3 (8.3) 이 모듈은 그것을 이미 알고 기록해 두었다

autoconfigure/GraphQlPolicyRequestPathTest가 그 목적으로 존재하는 테스트 클래스이고, javadoc이 이 sub-scope의 두 사실을 정확히 서술한다:

"Which policies are on the request path, and which only look as though they are (GQL-INT-003)."

"Cursor signing is not on the request path. backend.graphql.cursor.key-ids is consumed in exactly two places — GraphQlPlatformStartupValidator, which refuses to start a production deployment without it, and GraphQlPlatformActuatorEndpoint, which reports it back. Nothing signs a cursor with it. ... So production demands a key identity, an operator supplies one, the endpoint confirms it is configured — and cursors remain exactly as client-editable as they were, which is the thing the validator's own message says the key prevents."

"Mutation idempotency is not on the request path either. GraphQlMutationIdempotencyInterceptor is referenced by no configuration."

"Both are honest modelled capabilities by this leaf's own grading table — the defect is not that they are unfinished, it is that a startup validator makes one of them look finished."

그리고 그 사실을 테스트가 고정한다 — "this test fails the moment somebody wires one half without the other."

23.4 (8.4) 등급표와의 대조

CLAUDE.md의 등급표가 이 sub-scope의 네 패키지를 이렇게 매긴다:

영역 등급 확인
DataLoader/batching (dataloader/) wired 일치 — GraphQlBatchLoaderRegistrar가 배선하고 endpoint 테스트가 증명
cursor 서명 (pagination/) modelled 일치 — "auto-configuration이 둘 중 무엇도 생성하지 않는다"
mutation 멱등성 (mutation/) modelled 일치
fetch/ (표에 없음) main 소비자 0, 테스트만

24. Sub-scope 06 findings

24.1 P2 — 시작 검증기가 제공되지 않는 보안 성질을 요구한다

GraphQlPlatformStartupValidator가 프로덕션 배포를 다음 메시지로 거부한다:

"a cursor signing key is required; unsigned cursors are client-editable"

그리고 그 키를 받아 서명하는 코드가 없다(§23.2). 운영자 관점의 연쇄는 이렇다:

  1. 프로덕션 배포가 키 없이 시작을 거부당한다 → 키가 중요하다고 학습한다.
  2. backend.graphql.cursor.key-ids를 설정한다 → 부팅에 성공한다.
  3. 액추에이터 보고서가 그 키 식별자를 확인해 준다(그 엔드포인트를 §8.1대로 등록하면).
  4. 커서는 서명되지 않은 채로 남는다 — 검증기 메시지가 막는다고 말한 바로 그 상태다.

이것은 미구현이 아니라 잘못된 확인 신호다. 페이지네이션이 아직 어떤 feature에도 붙지 않았으므로 지금 조작될 커서 자체가 없다. 위험은 adopter가 커서 페이지네이션을 붙이는 시점에 발생한다 — 그때 플랫폼은 이미 "키가 있으니 서명된다"는 세 가지 신호(부팅 거부 · 설정 수용 · 보고서 확인)를 준 상태다.

이 모듈이 그것을 스스로 기록했다는 사실이 판정을 바꾸지 않는다. GraphQlPolicyRequestPathTest의 javadoc이 정확히 그렇게 말한다 — "the defect is not that they are unfinished, it is that a startup validator makes one of them look finished." 기록은 완전하고 진단은 정확하며, 운영자에게 도달하는 신호는 여전히 세 개 다 긍정이다.

권고 — 그 테스트가 이미 적어 둔 대로 "설계 결정이 먼저"다: GraphQlCursorKeyRing.ofMap<String, byte[]>를 받고 설정은 키 자체를 담지 않기로 했으므로, 키 자료의 출처(외부 시크릿 포트)를 정해야 배선할 수 있다. 그때까지의 최소 조치는 검증기 메시지를 사실에 맞추는 것이다 — 예: "a cursor signing key identity is required for the planned signed-cursor capability; cursor signing is not yet on the request path."

24.2 P3/기록 — fetch(10) · pagination 나머지(15) · mutation 나머지(13)는 adopter 대기 라이브러리다

CLAUDE.md가 이 leaf의 범위를 "feature-agnostic: 정책·계약·검증 기계만"으로 규정하고 feature 스키마·컨트롤러·리졸버는 sample 모듈이 소유한다고 명시한다. fetch 프로파일 분류, 커서 조립, 뮤테이션 계약 검증은 전부 리졸버가 있어야 호출되는 것들이다.

grpc 모듈(§3.1)과 같은 형태이고, 문서와 기본값과 테스트가 일치한다. 결함이 아니다. 다만 fetch/는 등급표에 행이 없어, 일곱 개 modelled 항목과 달리 등급이 선언되지 않은 유일한 Stable 패키지다.

24.3 — dataloader 결함 없음

17개 파일이 GraphQlBatchLoaderRegistrar를 통해 배선되고, 배치 정책 레지스트리와 팩토리가 자동설정에 등록되며, 실제 graphql-java 실행으로 N+1 제거가 증명된다(50 parent → 3 downstream). GraphQlBatchErrorPolicy · GraphQlMissingKeyPolicy · GraphQlBatchTimeoutException이 배치 실패의 세 종류를 구분한다.

25. Sub-scope 06 완료 조건

  • denominator 69 / 69 FULL_READ
  • §8.1~§8.4 수행 — 58개 main 파일 배선 전수, 커서 키 두 소비처 추적, 모듈 자체 기록과 대조, 등급표 대조
  • P2 1건, 기록 1건
  • 소스 미변경

Sub-scope 07 — release (10 files, main 9 + test 1)

내부 상태: COMPLETE — 10 / 10 FULL_READ · 근거 evidence/raw/213-inbound-graphql-release-probes.txt

26. 무엇을 하는 코드인가

릴리스 게이트와 능력 매니페스트. 이 sub-scope는 파일 수로는 가장 작지만, 이 모듈의 정직성 장치가 어디에 있고 어디까지 작동하는지를 결정한다.

GraphQlReleaseGate(78)가 다섯 종류의 증거(schema · contract · performance · fault · compatibility)를 요구하고, 없으면 이름을 붙여 거부한다. GraphQlReleaseOverride(48)가 예외를 허용하되 그것도 명시적 기록을 요구한다.

GraphQlStableCapabilityManifest(91)가 능력을 네 등급으로 나눈다 — STABLE 13개 · ADVANCED 6개 · EXPERIMENTAL 3개 · UNSUPPORTED 11개. UNSUPPORTED 항목마다 대안이 지정돼 있고("uploads go through the Fileserver, atomic multi-step work goes through one mutation use case, and cross-request caching goes through the cache capability"), 그중 하나에는 강등 이력이 주석으로 남아 있다:

SPRING_DATA_REPOSITORY_AUTO_EXPOSURE — "Was an allowlisted Advanced capability. An allowlist that lets a repository back a GraphQL field is still a controller reaching a repository — the second canonical hard-stop in AGENTS.md — and a capability flag cannot make an architectural rule conditional. Resolvers reach storage through an application use case or not at all."

27. 이 모듈의 정직성 장치 — 그리고 그것이 이 분석에 미친 영향

CLAUDE.md가 네 등급을 정의하고 규칙을 선언한다:

등급 의미
modelled 정책·계약 객체가 있고 단위 테스트가 있다. 요청 경로에는 없다.
wired Spring 실행 경로에 연결돼 있고, 실제 endpoint 테스트가 그 사실을 증명한다.
integration-verified 실제 외부 시스템과의 통합 증거가 있다.
production-verified 실부하·장애 시나리오 증거가 있다.

"capability 마다 아래 등급을 쓰고, 현재 등급보다 높게 표현하지 않는다."

그리고 13개 행 중 일곱을 스스로 modelled로 강등한다 — object 인가 · cursor 서명 · mutation 멱등성 · persisted operation · subscription/WebSocket/SSE/RSocket · federation/incremental/codegen/compat. 그중 둘(cursor 서명 · mutation 멱등성)은 전용 테스트 GraphQlPolicyRequestPathTest가 "누군가 한쪽만 배선하는 순간 실패"하도록 고정한다.

이것은 이 저장소에서 가장 강한 자기 공시다. 앞선 두 모듈과 대조하면:

inbound-web inbound-graphql
미배선 계층의 규모 main 397 중 다수 main 411 중 다수
그 사실의 문서화 없음 — README는 반대로 서술(§8.1) 등급표 13행 + 전용 테스트 + 티켓 ID(GQL-INT-003)
검증 장치가 그것을 가리는가 예 — 픽스처가 조립하고 레인이 픽스처를 인증(§48.1) 아니오 — modelled인 것을 wired라고 주장하지 않는다

따라서 이 모듈에 대한 분석의 초점은 "무엇이 미배선인가"가 아니라 **"등급표가 실제 배선과 일치하는가"**가 된다. 미배선이면서 등급표가 그것을 인정하는 항목은 결함이 아니고, 등급표가 인정하지 않는 항목만 결함이다.

28. Negative-space probes — sub-scope 07

28.1 (8.4) 등급표 13행 대 배선 전수 — 전수 대조

sub-scope 02~06의 파일 단위 배선 데이터와 등급표를 맞춘 결과:

등급표 행 선언 관측 판정
실행 파이프라인 / 인가 / cost 예산 wired GraphQlExecutionChain(autoconf=5) · GraphQlAuthorizationPolicy(5) · GraphQlCostBudgetHandler(2) 일치
depth/complexity 제한 wired GraphQlStructuralLimitPolicy(4) · GraphQlComplexityCalculator(4) · GraphQlDocumentShapeAnalyzer(4) 일치
preparsed document cache wired BoundedPreparsedDocumentProvider(4) · GraphQlPreparsedDocumentAdapter(4) 일치
커스텀 scalar wired GraphQlScalarWiringConfigurer(7) 일치
요청 크기/Accept 협상 (http/) wired GraphQlRequestEnvelopeValidator 0·0 · GraphQlAcceptHeader 0·1, 인용된 두 테스트 모두 단위 테스트 불일치 → §20.1
관측 tag cardinality wired GraphQlSensitiveAttributeFilter(6) · GraphQlRequestObservationConvention(4) 일치
object 인가 modelled ApplicationObjectAuthorization main_other=1 일치
DataLoader/batching wired GraphQlBatchPolicyRegistry(4) · GraphQlDataLoaderFactory(4) + GraphQlBatchLoaderRegistrar(4) 일치
cursor 서명 modelled HmacGraphQlCursorCodec 0·0 일치(§24.1은 등급이 아니라 검증기 메시지에 대한 것)
mutation 멱등성 modelled GraphQlMutationIdempotencyInterceptor 0·0 일치
persisted operation modelled §9에서 확인
subscription / WS / SSE / RSocket modelled §8에서 확인
federation / incremental / codegen / compat modelled §10에서 확인

13행 중 12행이 일치하고 한 행이 어긋난다.

28.2 (8.2) 조건 형제 비교 — 두 능력 목록이 커서에 대해 다르게 답한다

GraphQlStableCapabilityManifest.STABLESIGNED_CURSOR_CONNECTION이 들어 있다. CLAUDE.md 등급표는 cursor 서명을 modelled(요청 경로에 없음)로 매긴다.

두 목록의 용도가 다르다 — 매니페스트는 requireStable(capability)릴리스 게이트가 소비하는 기계 판정이고, 등급표는 사람이 읽는 공시다. 그러나 같은 능력에 대해 하나는 "Stable에서 지원"이라 하고 하나는 "요청 경로에 없음"이라 한다. §29.2.

28.3 (8.1) 도달성 — 릴리스 게이트 자체

release 9개 파일 전부 autoconf=0이다. 릴리스 게이트는 런타임 컴포넌트가 아니라 빌드·릴리스 시점 도구이므로 정상이다. 다만 GraphQlReleaseReportWriter(57)는 main_other=0 · test=1로, 게이트 결과를 기록할 작성기에 호출자가 없다.

CLAUDE.md가 그 상태를 명시한다 — "graphqlPerformanceTest 레인이 자리를 예약, 증거 없으면 릴리스 게이트가 거부". 즉 게이트는 CI 레인에서 호출되도록 설계됐다.

28.4 (8.3) 중복 메커니즘 — 없음

능력 등급은 매니페스트(기계)와 CLAUDE.md 표(사람) 두 곳에 있고 그것은 의도된 이중화다. 문제는 중복이 아니라 §28.2의 불일치다.

29. Sub-scope 07 findings

29.1 P2 — http/ 행이 등급표의 자기 규칙을 어긴다 (§20.1 참조)

§28.1. 13행 중 유일하게 관측과 어긋나는 행이고, 어긋나는 방향이 높게 표현하는 쪽이다 — 등급표가 명시적으로 금지한 방향이다("현재 등급보다 높게 표현하지 않는다"). 상세와 권고는 §20.1.

29.2 P3 — 기계가 읽는 능력 매니페스트와 사람이 읽는 등급표가 커서 서명에 대해 다르게 답한다

GraphQlStableCapabilityManifest.STABLESIGNED_CURSOR_CONNECTION을 지원 목록에 넣는다. 그 목록의 용도는 requireStable(...)이고, javadoc은 이렇게 말한다:

"Verifies a capability may be activated on the Stable starter. @throws GraphQlReleaseException when it is Advanced, Experimental or unsupported"

즉 이 매니페스트는 "Stable 스타터에서 켜도 되는가"를 판정한다. cursor 서명은 켤 수 있는 것으로 판정되고, 켜는 코드는 없다.

실패 시나리오 — adopter가 릴리스 게이트를 돌려 SIGNED_CURSOR_CONNECTION이 Stable에서 승인되는 것을 확인하고, 그것을 근거로 커서 페이지네이션을 Stable 계약의 일부로 문서화한다. §24.1의 세 긍정 신호(부팅 거부 · 설정 수용 · 액추에이터 보고)에 네 번째가 더해진다.

권고 — 매니페스트에 MODELLED 집합을 추가하거나, SIGNED_CURSOR_CONNECTIONEXPERIMENTAL로 옮긴다. 등급표와 매니페스트가 같은 사실을 말하도록 두 목록을 한 테스트로 묶는 것이 더 낫다 — 이 leaf는 이미 WebArchitectureRulesTest 형태의 개수 고정을 두 번 쓰고 있다(§3.4).

29.3 P3/기록 — GraphQlReleaseReportWriter에 호출자가 없다

§28.3. 게이트(verify)와 증거 모델(GraphQlReleaseEvidence)은 서로를 참조하지만 결과를 기록하는 작성기는 어디서도 불리지 않는다. 릴리스 레인이 아직 이 leaf 밖에 있으므로(“증거 자체는 실제 부하 인프라를 요구하므로 이 leaf 밖에서 생성한다”) 현재로서는 정합적이지만, 게이트를 부르는 코드가 저장소에 없다는 사실은 함께 기록해 둔다.

30. Sub-scope 07 완료 조건

  • denominator 10 / 10 FULL_READ
  • §8.1~§8.4 수행 — 등급표 13행 전수 대조, 두 능력 목록 비교, 릴리스 게이트 도달성
  • P2 1건(§20.1과 동일 사안), P3 1건, 기록 1건
  • 소스 미변경

Sub-scope 08 — advanced/ 스트리밍 (subscription·websocket·sse·incremental·rsocket) (51 files, main 45 + test 6)

내부 상태: COMPLETE — 51 / 51 FULL_READ · 근거 evidence/raw/214-inbound-graphql-advanced-streaming-probes.txt

31. 관측과 등급의 대조

45개 main 파일 전부 autoconf=0이다. 등급표의 선언과 정확히 일치한다:

subscription / WebSocket / SSE / RSocket | modelled | 정책·상태기계 단위 테스트만. Spring transport handler 는 없다(그래서 타입 이름도 *Admission 이다)

괄호 안의 문장이 이 sub-scope에서 가장 값이 크다 — 타입 이름이 등급을 인코딩한다. GraphQlWebSocketAdmission(71) · GraphQlSseAdmission(63) · GraphQlRSocketAdmission(55)은 "허용 판정"이지 핸들러가 아니다.

*HandlerFactory 세 개(281 LOC)도 이름과 달리 핸들러를 만들지 않는다. 각각의 javadoc이 "Decides whether the ... handler may exist, and on what terms"로 시작하고, 팩토리인 이유를 세 입력으로 설명한다:

GraphQlWebSocketHandlerFactory — "A factory rather than a bean definition because the decision has three inputs and only one of them is a flag: the capability must be enabled, the properties must describe a connection that can actually be bounded, and the admission policy must be present. A configuration class that checked only the flag would produce a handler with an unbounded connection lifetime whenever a deployment forgot the rest, and an unbounded WebSocket is a connection slot held by whoever opens it."

GraphQlSseHandlerFactory는 SSE 변형의 성질을 미리 밝힌다 — "one POST per subscription, each holding its own connection... a client with twenty subscriptions holds twenty connections and a browser's six-per-origin limit is [the surprise]."

GraphQlRSocketHandlerFactory는 실험 등급에 게이트를 하나 더 건다 — "A flag can be set by anyone editing configuration; the approval is a separate act, and separating them is what stops an experimental transport from being switched on the way a supported one would be."

32. Findings — 없음

45개 파일이 선언된 등급(modelled)과 일치하고, 상태기계·정책·종료 조건이 단위 테스트로 덮여 있다. 이름 규약(*Admission)이 등급을 코드에서 읽을 수 있게 만든다. 이 sub-scope에는 등급표와 어긋나는 항목이 없다.

§20.3에서 기록한 GraphQlSubscriptionExceptionResolver(미배선)도 이 등급 안에 있다.

33. 완료 조건 — denominator 51 / 51 FULL_READ · 소스 미변경


Sub-scope 09 — advanced/ 요청 성형 (persisted·get·replay·chaining·admin) (53 files, main 46 + test 7)

내부 상태: COMPLETE — 53 / 53 FULL_READ · 근거 evidence/raw/215-inbound-graphql-advanced-shaping-probes.txt

34. 관측과 등급의 대조

46개 main 파일 전부 autoconf=0. 등급표: "persisted operation (advanced/persisted/) | modelled | 중립 OperationalRecordStorePort 기반 레지스트리 + 방향성 테스트. durable 구현체는 미제공".

persisted(15)의 의존 방향이 특히 잘 잡혀 있다. CLAUDE.md가 그 이유를 적는다:

"이 leaf 는 중립 계약 dev.caskeleton.shared.opstore.OperationalRecordStorePort 에만 의존하고 key/value 매핑만 소유한다. Postgres/Redis 구현체는 그 중립 계약을 구현하며, 이 leaf 의 타입을 구현하지 않는다(그랬다면 인프라 → 인바운드 전송으로 의존이 뒤집힌다)."

GraphQlPersistedOperationId(main_other=10) · GraphQlPersistedOperation(9) · GraphQlPersistedOperationTransition(80) · GraphQlPersistedOperationStatus가 등록·승인·폐기 상태기계를 이룬다. advanced/admin(10)이 그 위의 관리 연산이고, testFixtures가 인메모리 구현 둘(InMemoryGraphQlPersistedOperationRegistry · InMemoryGraphQlPersistedOperationAdminPort)을 제공한다.

advanced/get(7)은 HTTP GET 초안 프로파일, advanced/replay(8)는 구독 재생 위치, advanced/chaining(6)은 DataLoader 체이닝이다 — 셋 다 EXPERIMENTAL 또는 ADVANCED 등급.

35. Findings — 없음

선언된 등급과 관측이 일치하고, 의존 방향이 명시적으로 논증돼 있으며, 상태기계가 테스트로 덮여 있다.

36. 완료 조건 — denominator 53 / 53 FULL_READ · 소스 미변경


Sub-scope 10 — advanced/ 스키마·플랫폼 (federation·composition·codegen·springdata·security·release·bootstrap) (59 files, main 50 + test 9)

내부 상태: COMPLETE — 59 / 59 FULL_READ · 근거 evidence/raw/216-inbound-graphql-advanced-platform-probes.txt

37. 무엇을 하는 코드인가

advanced/bootstrap (6) 이 Advanced 활성화 모델이다. GraphQlAdvancedCapability(59, main_other=15) · GraphQlAdvancedFeatureFlags(40) · GraphQlAdvancedModuleGuard(63) · GraphQlAdvancedCapabilityDisabledException.

GraphQlAdvancedFeatureFlags의 첫 문장이 이 분리의 이유다:

"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."

advanced/springdata (7) 은 강등의 흔적이다. GraphQlStableCapabilityManifest.UNSUPPORTEDSPRING_DATA_REPOSITORY_AUTO_EXPOSURE가 있고 그 옆에 이유가 적혀 있다 — "Was an allowlisted Advanced capability. An allowlist that lets a repository back a GraphQL field is still a controller reaching a repository — the second canonical hard-stop in AGENTS.md — and a capability flag cannot make an architectural rule conditional."

advanced/composition(8) · federation(7) · codegen(8) · release(7) · security(7) 은 전부 modelled.

38. Negative-space probes

38.1 (8.1) 도달성 — Stable 자동설정이 Advanced를 건드리지 않는가

50개 main 파일 전부 autoconf=0이다. 그리고 그것은 의도된 것이다 — GraphQlPlatformStartupValidator의 거부 규칙 중 하나가 "the Stable starter must not activate Advanced capabilities"다. Stable 루트가 Advanced를 참조하지 않는 상태가 그 규칙의 구조적 형태다.

AutoConfiguration.imports에는 항목이 하나(GraphQlRootAutoConfiguration)뿐이므로 Advanced 진입점은 존재하지 않는다.

38.2 (8.4) 문서/구현 드리프트 — "기본 비활성"이라는 서술

CLAUDE.md:180:

"Advanced capability 는 전부 기본 비활성이다(advanced/bootstrap/GraphQlAdvancedFeatureFlags). EXPERIMENTAL 등급(RSocket, incremental delivery, HTTP GET draft)은 명시적 승인 없이는 GraphQlAdvancedModuleGuard 가 production 활성화를 거부한다."

GraphQlAdvancedFeatureFlags@ConfigurationProperties가 아니라 정적 팩토리(disabled() · enabling(...) · withExperimentalApproval())만 가진 record다. backend.graphql.advanced.* 같은 프로퍼티 접두사가 저장소 어디에도 없고, Advanced 자동설정도 없다. §39.1.

39. Findings

39.1 P3 — "기본 비활성"은 존재하지 않는 스위치의 기본값을 서술한다

§38.2. Advanced 능력을 켤 설정 표면이 없다 — 플래그 record는 코드에서만 만들어지고(enabling(...)은 테스트가 부른다), 그것을 바인딩하거나 소비하는 자동설정이 없다.

등급표는 이 상태를 정확히 말한다 — Advanced 항목이 전부 modelled("요청 경로에는 없다")이므로, 능력이 동작한다고 주장하지 않는다. 어긋나는 것은 CLAUDE.md 산문의 활성화 서술뿐이다: "기본 비활성"과 "명시적 승인 없이는 production 활성화를 거부한다"는 둘 다 활성화 경로의 존재를 전제한다.

inbound-web §36.1과 같은 형태이되 심각도가 다르다. 거기서는 11개 능력이 선언되고 2개만 켤 수 있으면서 그 사실이 어디에도 없었다. 여기서는 켤 수 없다는 사실이 등급표에 modelled로 적혀 있고, 산문 한 문단만 그보다 앞서 나간다.

권고 — 그 문단을 등급에 맞춘다: "Advanced capability 는 현재 modelled 등급이며 활성화 경로가 없다. GraphQlAdvancedFeatureFlags·GraphQlAdvancedModuleGuard는 그 경로가 생길 때 쓸 판정 모델이다."

39.2 — 그 외 결함 없음

advanced/springdata의 강등 기록, Stable 루트가 Advanced를 참조하지 않는 구조, GraphQlAdvancedModuleGuard의 두 단계(플래그 + 실험 승인) 모두 선언과 일치한다.

40. 완료 조건 — denominator 59 / 59 FULL_READ · P3 1건 · 소스 미변경


Sub-scope 11 — testFixtures + test 잔여 (21 files, testFixtures 16 + test 5)

내부 상태: COMPLETE — 21 / 21 FULL_READ · 근거 evidence/raw/217-inbound-graphql-testkit-probes.txt

41. 무엇을 하는 코드인가

testFixtures(16)가 계약 스위트와 통합 픽스처를 담는다 — GraphQlSchemaContractSuite · GraphQlSecurityContractSuite · GraphQlHttpContractSuite · GraphQlPaginationContractSuite · GraphQlDataLoaderContractSuite 다섯 개 계약 스위트와, GraphQlJpaIntegrationFixture · GraphQlMongoIntegrationFixture · GraphQlStorageIntegrationEvidence 세 개 저장소 통합 계약, 그리고 GraphQlDownstreamFailureFixture · GraphQlPartialResponseFixture · GraphQlContractViolation · GraphQlRequestContexts.

advanced 아래 인메모리 구현 둘(InMemoryGraphQlPersistedOperationRegistry · InMemoryGraphQlPersistedOperationAdminPort)이 modelled persisted operation의 테스트용 저장소다.

42. Negative-space probes

42.1 (8.1) 도달성 — 통합 증거 계약의 위치

CLAUDE.md가 이 소스셋의 경계를 명시한다:

"실제 datastore 통합 증거 — testkit/GraphQlJpaIntegrationFixture / GraphQlMongoIntegrationFixture 가 계약을 정의하고 GraphQlStorageIntegrationEvidence 가 증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다."

즉 이 픽스처들은 계약만 정의하고 실행은 다른 leaf가 한다 — testFixtures 소스셋으로 발행하는 이유다.

42.2 (8.3) 중복 메커니즘 — 계약 스위트와 이 leaf의 테스트

다섯 개 계약 스위트는 adopter가 자기 스키마·리졸버에 대해 돌릴 수 있도록 만들어졌고, 이 leaf 자신의 103개 테스트와 목적이 다르다(전자는 채택자용 계약, 후자는 이 leaf의 구현). 중복 아님.

43. Findings — 없음

44. 완료 조건 — denominator 21 / 21 FULL_READ · 소스 미변경


45. 모듈 종합 — adapter-inbound-graphql

45.1 커버리지 원장 정산

# sub-scope main test 기타
1 governance + autoconfigure + moduleboundary + architecture + api 35 21 4 60
2 schema + scalar + compat 37 9 46
3 execution + context + runtime 48 12 60
4 cost + policy + security 45 12 57
5 http + error + observation 38 10 48
6 dataloader + fetch + pagination + mutation 58 11 69
7 release 9 1 10
8 advanced/ 스트리밍 45 6 51
9 advanced/ 요청 성형 46 7 53
10 advanced/ 스키마·플랫폼 50 9 59
11 testFixtures + test 잔여 0 5 16 21
합계 411 103 20 534

FULL_READ 534 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0. 각 sub-scope의 분모는 evidence 207217OWNED FILES 블록이 확인한다.

45.2 발견 종합 — P1 0건 · P2 5건 · P3 6건 · 기록 3건

심각도 § 발견
P2 8.1 스키마 조립·계약 정체성·해시 사슬이 통째로 미배선이고, 그것을 발행할 액추에이터 엔드포인트(GraphQlPlatformActuatorEndpoint)도 @Bean이 없다
P2 12.1 설계 §10의 5계층 예산 모델에서 요청 계층만 강제되고 리졸버·DataLoader 배치·다운스트림 파생이 전부 미배선(GraphQlDeadlinePropagator 호출자 0)
P2 16.1 설정으로 정한 파서 한계가 graphql-java에 설치되지 않는다(GraphQlParserOptionsFactory 호출자 0) — backend.graphql.limits.*가 파서에 닿지 않는다
P2 16.2 프로파일별 정책 매니페스트가 미배선이라, 자격에서 정확히 해석된 클라이언트 프로파일이 캐시 키와 지표 태그만 고르고 예산은 고르지 않는다
P2 20.1 / 29.1 등급표의 http/ 행이 wired로 선언돼 있으나 인용된 두 증거 테스트가 endpoint 테스트가 아니고 대상 타입은 미배선 — 등급표 13행 중 유일하게 관측과 어긋나는 행이며, 어긋나는 방향이 표가 금지한 "높게 표현" 쪽
P2 24.1 시작 검증기가 "unsigned cursors are client-editable"로 프로덕션을 거부하며 커서 서명 키를 요구하고, 그 키로 서명하는 코드가 없다 — 부팅 거부·설정 수용·액추에이터 확인 세 신호가 전부 긍정
P3 8.2 · 12.2 · 12.3 · 16.3 · 20.2 · 29.2 · 39.1 @oneOf 게이트/검증기 미배선 · 연산 이름 정책 이원화 · 리졸버 카탈로그 공백 · 중복 4타입 · 파싱/검증 실패 매퍼 부재 · 기계 매니페스트와 등급표가 커서에 대해 다른 답 · "기본 비활성"이 없는 스위치를 서술

45.3 이 모듈의 성격 — 자기 공시가 작동하는 첫 사례

P1이 없다. 그리고 그 이유가 이 모듈의 핵심이다.

이 leaf도 앞선 두 모듈과 같은 병을 갖는다 — main 411 파일 중 프로덕션 요청 경로에 있는 것은 소수이고, 정책·계약 객체 다수가 단위 테스트만 갖는다. 차이는 그 사실을 스스로 등급으로 말한다는 점이다.

| 등급         | 의미                                                              |
| modelled     | 정책·계약 객체가 있고 단위 테스트가 있다. 요청 경로에는 없다.        |
| wired        | Spring 실행 경로에 연결돼 있고, 실제 endpoint 테스트가 그 사실을 증명한다. |
...
"capability 마다 아래 등급을 쓰고, 현재 등급보다 높게 표현하지 않는다."

13개 행 중 일곱을 스스로 modelled로 강등하고, 그중 둘(cursor 서명 · mutation 멱등성)은 전용 테스트 GraphQlPolicyRequestPathTest가 "누군가 한쪽만 배선하는 순간 실패"하도록 고정한다. 그 테스트의 javadoc은 이 분석이 도달했을 결론을 먼저 적어 두었다:

"Which policies are on the request path, and which only look as though they are (GQL-INT-003). ... the defect is not that they are unfinished, it is that a startup validator makes one of them look finished."

그리고 타입 이름까지 등급을 인코딩한다 — 스트리밍 전송이 *Handler가 아니라 *Admission인 이유가 등급표에 명시돼 있다.

세 모듈의 대조:

inbound-web (14) inbound-grpc (15) inbound-graphql (16)
main 파일 400 8 411
조립 진입점 자동설정 2 + 컴포넌트 스캔 + app-bootstrap 19타입 @Configuration 1 자동설정 1(루트) + import 필터 1 + 환경 후처리기 1
미배선 계층 다수 없음 다수
그 사실의 공시 없음 — README가 반대로 서술 해당 없음 등급표 13행 + 전용 테스트 + 티켓 ID
P1 6 0 0

규모가 아니라 공시가 P1을 만들거나 없앤다. inbound-web과 graphql은 미배선 규모가 비슷하고, web은 P1 여섯 건, graphql은 0건이다. 차이는 web의 README가 실제와 반대되는 계약을 서술하고 다섯 레인이 픽스처의 조립을 인증한 반면, graphql은 미배선을 등급으로 선언하고 그 선언을 테스트로 고정했다는 점이다.

그래서 이 모듈의 P2 다섯 건 중 가장 무거운 것은 §20.1이다 — 등급표의 한 행이 틀렸다는 것. 그 표가 이 모듈의 주된 정직성 장치이므로, 표를 읽고 신뢰하는 사람은 정확히 그 행에서 틀린 결론을 얻는다. 나머지 네 건(§8.1 · §12.1 · §16.1 · §16.2)은 등급표에 행이 없는 영역이다 — 스키마 해시 사슬, 예산 계층, 파서 한계, 정책 매니페스트. 표가 덮지 않는 곳이 결함이 모이는 곳이라는 관찰이 이 모듈의 결론이다.

45.4 실행 검증

$ ./gradlew :adapter:inbound:graphql:test
> Task :adapter:inbound:graphql:test
BUILD SUCCESSFUL in 15s
GRADLE_EXIT=0

test-results 집계: classes=186 tests=1603 failures=0 errors=0 **skipped=0**

CLAUDE.md가 세 개 플랫폼 레인을 더 문서화한다 — graphqlStableTest(605) · graphqlContractTest(9) · graphqlAdvancedTest(152). 기본 testquarantine·graphql-performance 태그를 제외한다. 이 분석은 기본 레인만 실행했다.

45.5 완료 게이트

  • denominator 534 / 534 FULL_READ, STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0
  • 11개 sub-scope 각각 §8.1~§8.4 negative-space probe 수행 — 411개 main 파일 전부에 대해 (autoconf 참조 / 자기·autoconfigure 제외 main 참조 / test 참조) 삼중 카운트를 기계 산출
  • evidence 205217 생성 (패키지 도달성 지도 206 포함)
  • 실행 검증: :adapter:inbound:graphql:testBUILD SUCCESSFUL, tests=1603 failures=0 skipped=0
  • 거짓 양성 후보 검증 후 기각: GraphQlContextPropagator의 ThreadLocal 누수 의심(→ 네 진입점 전부 finally 복원, 테스트가 확인) · GraphQlOperationNameInterceptor 미배선이 익명 연산 허용으로 이어진다는 의심(→ 배선된 GraphQlOperationSelectionHandler가 네 가지 거부를 모두 수행) · @oneOf 런타임 미검증 의심(→ graphql-java 25.0이 자체 처리) · advanced/* 전면 미배선이 결함이라는 의심(→ 등급표가 modelled로 선언)
  • 분석 중 판정 변경: sub-scope 05를 처음 "HTTP 전송 계층 미배선"으로만 기록했으나, sub-scope 07에서 CLAUDE.md 등급표를 읽은 뒤 그 행이 wired로 선언돼 있다는 사실이 드러나 §20.1을 등급표 위반으로 다시 썼다. 근거 evidence/raw/211·213
  • 소스 미변경

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 169개 (main 149 · test 19 · 기타 1).

src/adapter/inbound/graphql/build.gradle
src/config/architecture/modules.json                    (adapter-inbound-graphql 항목)

main:
  src/main/java/dev/caskeleton/adapter/inbound/graphql/HealthGraphqlController.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapabilityDisabledException.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedFeatureFlags.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuard.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperation.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationId.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationStatus.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationTransition.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketAdmission.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketHandlerFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseAdmission.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseHandlerFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketAdmission.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketHandlerFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlActivationEnvironmentPostProcessor.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlOffAutoConfigurationImportFilter.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformActuatorEndpoint.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformConfigurationReport.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformSettings.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformStartupValidator.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRetiredSafetyAxis.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRootAutoConfiguration.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlChangeKind.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlClientOwnerApproval.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlCompatibilityPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlDeprecationGate.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlRemovalDecision.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/context/ActorRef.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlDeadline.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlIdentityFingerprinter.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlRequestContext.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/context/TenantContext.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlComplexityCalculator.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlCostCatalog.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlDocumentComplexityScorer.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlDocumentShapeAnalyzer.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlFieldCostDescriptor.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlParserLimitPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlParserLimits.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlParserOptionsFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlResolverWeight.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlResponseByteLimiter.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlResponseNodeCounter.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlRuntimeBudgetTracker.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlStructuralLimitPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlStructuralLimits.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchChunker.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchErrorPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchExecutor.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchPolicyRegistry.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchResultMapper.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchTimeoutException.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlDataLoaderFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlMissingKeyPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlErrorContext.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlExceptionResolver.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlInternalErrorMasker.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlNullabilityContract.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlRequestErrorMapper.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlSubscriptionExceptionResolver.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlWireError.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/BoundedPreparsedDocumentProvider.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlCancellation.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlDeadlinePropagator.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionProfileValidator.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlOperationNameInterceptor.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlOperationNamePolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlPreparsedCacheMetrics.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlPreparsedCachePolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlResolverBudget.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlResolverCatalog.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlResolverDescriptor.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlTimeoutPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlAcceptHeader.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlExecutionOutcome.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlExtensionsPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpExecutor.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpOutcome.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpRequestEnvelope.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpResponse.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpResponseFactory.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpResponsePolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpStatusMapper.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlJsonStructurePolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlJsonValues.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlMediaTypes.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlRequestEnvelopeValidator.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlRequestSize.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlCanonicalInput.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationContractValidator.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationFingerprint.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlDataLoaderObservationConvention.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlMetricCardinalityPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlOperationNameCardinality.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlProfilerAccessPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlRequestObservationConvention.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlResolverObservationConvention.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlSensitiveAttributeFilter.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlConnectionAssembler.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorFraming.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorKeyRing.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorPayload.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/HmacGraphQlCursorCodec.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/policy/GraphQlClientPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/policy/GraphQlClientPolicyManifest.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/policy/GraphQlOperationCatalog.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/policy/GraphQlPolicyViolation.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/release/GraphQlReleaseEvidence.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/release/GraphQlReleaseGate.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/release/GraphQlReleaseOverride.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/release/GraphQlReleaseReportWriter.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/release/GraphQlStableCapabilityManifest.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrar.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBlockingBridge.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlCostBudgetHandler.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlDataFetcherExceptionResolver.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionChain.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlOperationSelectionHandler.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformInstrumentation.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformWebInterceptor.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPreparsedDocumentAdapter.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPrincipalResolver.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlRequestObservationConventionAdapter.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlWireErrorMapper.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/servlet/GraphQlRequestBodyLimitFilter.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/GraphQlDecimalBounds.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/GraphQlScalarWiringConfigurer.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlMappingInspectionGate.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlMappingIssue.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlMappingPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlOneOfInputValidator.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlOneOfPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlOneOfSchemaGate.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlScalarDefinition.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlScalarManifest.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlSchemaAssembler.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlSchemaAssemblyResult.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlSchemaContract.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/schema/GraphQlSchemaHash.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/security/ApplicationObjectAuthorization.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthorizationPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlClientProfileResolver.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlContextCleanup.java
  src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlContextPropagator.java

test:
  src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlObservationWiringTest.java
  src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPolicyRequestPathTest.java
  src/test/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlAcceptNegotiationTest.java
  src/test/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlRequestBoundsTest.java
  src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrationTest.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/InMemoryGraphQlPersistedOperationAdminPort.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/InMemoryGraphQlPersistedOperationRegistry.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlPartialResponseFixture.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractViolation.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlDataLoaderContractSuite.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlDownstreamFailureFixture.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlHttpContractSuite.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlJpaIntegrationFixture.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlMongoIntegrationFixture.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlPaginationContractSuite.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlRequestContexts.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlSchemaContractSuite.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlSecurityContractSuite.java
  src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlStorageIntegrationEvidence.java

기타:
  CLAUDE.md

해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등:
  evidence/raw/205-inbound-graphql-module-inventory.txt
  evidence/raw/207-inbound-graphql-autoconfigure-probes.txt
  evidence/raw/208-inbound-graphql-schema-probes.txt
  evidence/raw/209-inbound-graphql-execution-probes.txt
  evidence/raw/210-inbound-graphql-cost-security-probes.txt
  evidence/raw/211-inbound-graphql-http-probes.txt
  evidence/raw/212-inbound-graphql-data-probes.txt
  evidence/raw/213-inbound-graphql-release-probes.txt
  evidence/raw/214-inbound-graphql-advanced-streaming-probes.txt
  evidence/raw/215-inbound-graphql-advanced-shaping-probes.txt
  evidence/raw/216-inbound-graphql-advanced-platform-probes.txt
  evidence/raw/217-inbound-graphql-testkit-probes.txt


A17. adapter-inbound-websocket

분석 중에는 17-adapter-inbound-websocket.md 파일이었다. 715줄.

adapter-inbound-websocket — 코드베이스 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: adapter-inbound-websocket
  • canonical state analysisFile: §A17 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/adapter/inbound/websocket · Gradle :adapter:inbound:websocket
  • registry allowed_dependencies: ["domain-core", "application-core", "shared-contract"]
  • registry runtime_memberships: []
  • coverage ledger: FULL_READ 253 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행)

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


분석 대상 src/adapter/inbound/websocket · revision a24ece9cf797f7ea647e33bf846b115208ed1ba5 분모 253 tracked files (main 169 · test 67 · testkit 7 · nginxWebSocketTest 4 · jettyWebSocketTest 1 · brokerRelayTest 1 · governance 4) LOC main Java 12,784 · test Java 9,435 근거 evidence/raw/218-inbound-websocket-module-inventory.txt, 이하 219226

0. 이 모듈의 형태 — 하나의 leaf, 세 개의 설정 네임스페이스

여섯 소스셋(inbound-web과 같은 형태)이고 META-INF 자동설정 리소스가 없다. 조립은 전적으로 컴포넌트 스캔에 달려 있으며, 컴포지션 루트는 이 leaf를 스캔에서 제외하지 않는다(graphql과 반대).

그런데 스캔이 잡을 수 있는 Spring 애노테이션을 가진 파일이 169개 중 7개다:

config/WebSocketPlatformSettings.java          @ConfigurationProperties(prefix = "backend.websocket")
stomp/WebSocketProperties.java                 @ConfigurationProperties(prefix = "ca-skeleton.websocket")
stomp/WebSocketConfig.java                     @Configuration + @ConditionalOnProperty("ca-skeleton.websocket.enabled")
advanced/sockjs/SockJsConfiguration.java       @Configuration + prefix "app.websocket-platform.advanced.sockjs"
advanced/stomp/StompConfiguration.java         @Configuration + prefix "app.websocket-platform.advanced.stomp"
advanced/stomp/StompDefaultsConfiguration.java @Configuration + prefix "app.websocket-platform.advanced.stomp"
advanced/stomp/rabbit/RabbitBrokerRelayConfiguration.java  prefix "app.websocket-platform.advanced.stomp.relay"
advanced/stomp/StompBrokerExclusivity.java

세 개의 설정 접두사가 있고 그중 하나에는 소비자가 없다:

접두사 설정 타입 @Configuration 소비자 yaml 등장
ca-skeleton.websocket.* stomp/WebSocketProperties stomp/WebSocketConfig 없음
app.websocket-platform.advanced.* (직접 @ConditionalOnProperty) 4개 없음
backend.websocket.* config/WebSocketPlatformSettings 0 없음

세 번째가 이 모듈의 핵심 사실이다. backend.websocket 네임스페이스가 규정하는 "플랫폼"이 main 169 파일 중 약 90개를 차지하고, 그것을 조립하는 @Configuration이 하나도 없다.

1. 커버리지 원장

# sub-scope main test 기타 상태
1 governance + config + moduleboundary + core + evidence 24 9 4 37 COMPLETE
2 protocol + codec + handshake + servlet + webflux 23 6 29 COMPLETE
3 handler + inbound + outbound + session + lifecycle + ordering 21 9 30 COMPLETE
4 security + authz + idempotency + budget + error + observability + admin + release 22 9 31 COMPLETE
5 stomp 8 5 13 COMPLETE
6 advanced/stomp + stomp/rabbit + cluster + resume 41 13 54 COMPLETE
7 advanced/ 잔여 (presence · codec 3종 · sockjs · graphql · compression · client · release · http2 · http3) 30 11 41 COMPLETE
8 testkit + 대체 소스셋 3종 0 5 13 18 COMPLETE
TOTAL 169 67 17 253 8 / 8

FULL_READ 253 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0. 분할은 218-...의 패키지 트리에서 기계 계산(중복 0 · 미할당 0).

evidence 파일의 autoconf 열에 대하여219226의 배선 표는 graphql 모듈에서 쓴 스크립트를 재사용했고, 그 열은 autoconfigure 패키지의 참조 수를 센다. 이 leaf에는 그런 패키지가 없으므로 전 행이 0이며 그 자체로는 정보가 없다. 이 모듈의 배선 판정은 위 §0의 애노테이션·네임스페이스 전수와 main_other 열로 한다.


Sub-scope 01 — governance + config + moduleboundary + core + evidence (37 files)

근거 evidence/raw/219-inbound-websocket-core-probes.txt

2. 무엇을 하는 코드인가

config (3) 이 플랫폼의 안전 장치 셋을 담는다.

WebSocketPlatformSettings(60)는 기본값 철학을 먼저 말한다:

"Every default here is the safe one, and where there is no safe default there is no default. allowedOrigins is empty and enabled is false: a deployment that has not said which origins may connect has not configured a WebSocket endpoint, and starting one anyway would mean the platform chose its own CSRF posture."

WebSocketPlatformStartupValidator(125)는 "개발에서는 완벽히 동작하고 프로덕션에서 틀린" 것만 잡는다고 선언한다:

"a cookie endpoint with no origin allowlist serves every same-origin test correctly, an unnegotiated-fallback profile connects fine until the message format changes, and an endpoint with no authorization requirement simply refuses everything — quietly, and only for the clients that try to use it."

WebSocketStackExclusivity(78)는 inbound-web §40.1에서 확인한 결함을 이름 붙여 탐지한다:

"Spring Boot deduces one application type from what is present, and the deduction is not negotiable: with both a servlet container and Reactor Netty available it starts the servlet one. A deployment that declared reactive endpoints and shipped both therefore starts, reports itself healthy, and serves none of them — no error, no warning, and the endpoints simply never answer."

core (12) 는 값 어휘다 — WebSocketActorReference(main_other=19) · WebSocketEndpointName(17) · WebSocketConnectionId(9) · WebSocketNodeId(7) · WebSocketSubprotocolName(7) · WebSocketEndpointCatalog(111) · WebSocketConnectionContext(97). 이 leaf에서 가장 널리 참조되는 타입들이다.

evidence (6) 는 연결·인바운드·아웃바운드 3축 증거 모델(WebSocketInboundEvidence 121 · WebSocketOutboundEvidence 92 · WebSocketConnectionEvidence 67).

moduleboundary (3)WebSocketStableModule(394줄 enum) + WebSocketModuleBoundary(98) + WebSocketModulePurity — inbound-web의 WebStableModule(539)과 같은 형태의 모듈 경계 선언이다.

3. Negative-space probes — sub-scope 01

3.1 (8.1) 도달성 — 세 안전 장치의 호출자
$ grep -rn 'WebSocketPlatformStartupValidator|WebSocketStackExclusivity' src/main src/test --include=*.java | grep -v config/
advanced/stomp/StompBrokerExclusivity.java:17:  * WebSocketStackExclusivity}: a runtime that silently half-applies is worse than one that will not

둘 다 프로덕션 호출자가 0이다. 유일한 참조는 다른 클래스의 javadoc이 설계를 인용한 것이다. WebSocketPlatformSettings도 소비자가 0이다(§0).

3.2 (8.2) 조건 형제 비교 — 두 개의 설정 검증
stomp/WebSocketProperties config/WebSocketPlatformSettings
접두사 ca-skeleton.websocket backend.websocket
검증 @Validated — "Invalid enabled settings fail context startup"(CLAUDE.md) WebSocketPlatformStartupValidator 125줄
소비 @Configuration WebSocketConfig 없음
CLAUDE.md 문서화 4개 키 표 없음
3.3 (8.3) 중복 메커니즘 — origin 허용목록이 두 곳에 있다

WebSocketPlatformSettings.allowedOrigins(기본 빈 집합, "no safe default there is no default")와 stomp/WebSocketProperties.allowedOriginPatterns()(기본 http://localhost:3000, CLAUDE.md가 "explicit HTTP(S) origins only; blank/wildcard/path rejected"로 규정)이 같은 결정을 두 번 표현한다. 실제 핸드셰이크에 적용되는 것은 후자다(WebSocketConfig.registerStompEndpoints).

3.4 (8.4) 문서/구현 드리프트 — CLAUDE.md가 서술하는 모듈과 실제 파일

CLAUDE.md의 Responsibility 다섯 줄이 전부 STOMP-over-SockJS 어댑터를 서술하고, Typed settings 표는 ca-skeleton.websocket.* 네 키만 담는다. backend.websocketapp.websocket-platform.advanced.* 두 네임스페이스는 등장하지 않는다.

Evidence 절이 일부를 명시적으로 면책한다:

"The simple broker is local, single-process, best-effort R1 evidence only. Broker relay, multi-node/durable delivery, rollback-safe publication, replay/resume, backpressure, and a versioned domain projection catalog are P2 and are not claimed."

이 면책이 덮는 것은 advanced/stomp/rabbit(7) · advanced/cluster(9) · advanced/resume(8)과 backpressure 관련 파일이다. 덮지 않는 것이 §4.1이다.

4. Sub-scope 01 findings

4.1 P2 — backend.websocket 플랫폼(약 90개 main 파일)에 조립 지점이 없고, 모듈 SSOT 문서에 존재하지 않는다

169개 main 파일이 세 덩어리로 나뉜다:

덩어리 파일 조립 CLAUDE.md
STOMP 어댑터 (stomp/) 8 WebSocketConfig (ca-skeleton.websocket.enabled=true) Responsibility 5줄 + 설정 표 4키 + 증거 절
Advanced (advanced/**) 71 4개 @Configuration (app.websocket-platform.advanced.*) 일부 면책("not claimed")
플랫폼 (config·core·protocol·handler·outbound·session·lifecycle·ordering·security·authz·idempotency·budget·error·evidence·observability·admin·release·handshake·codec·servlet·webflux·inbound·moduleboundary) 90 없음 없음

세 번째 덩어리에는 실질적인 기계가 들어 있다 — PlatformWebSocketHandler(205) · OutboundQueue(189) · SerializedOutboundWriter(188) · WebSocketSessionRegistry(163) · WebSocketCorrelationRegistry(162) · HandshakeAdmissionPipeline(145) · StrictWebSocketJsonCodec(145) · CloseOrchestration(138) · LateResponseTombstone(108) · FragmentAssembler(117).

가장 무거운 결과는 세 안전 장치가 실행되지 않는다는 것이다:

  1. WebSocketPlatformStartupValidator(125) — "Refuses to start a deployment whose WebSocket configuration is unsafe or incoherent." 호출자 0.
  2. WebSocketStackExclusivity(78) — 서블릿/리액티브 이중 스택에서 "reactive endpoints ... simply never answer"를 탐지. 호출자 0.
  3. WebSocketPlatformSettings의 "safe default" 규약 — 그 설정을 읽는 코드가 0.

실패 시나리오 — 팀이 이 leaf를 채택하며 backend.websocket.enabled=trueallowed-origins를 설정한다. @ConfigurationPropertiesScandev.caskeleton.adapter.inbound.websocket을 포함하므로 프로퍼티는 바인딩되고 검증도 통과한다(설정 자체는 유효하므로). 부팅이 성공하고 오류가 없다. WebSocket 엔드포인트는 하나도 열리지 않는다backend.websocket을 읽는 @Configuration이 없기 때문이다. 실제로 엔드포인트를 여는 스위치는 문서화된 ca-skeleton.websocket.enabled이고, 그것은 다른 8개 파일짜리 STOMP 어댑터를 켠다.

inbound-web·graphql과의 위치 — graphql은 같은 상태를 등급표로 공시했고(§45.3), web은 공시하지 않아 P1 여섯 건이 되었다. 이 모듈은 일부만 면책한다(§3.4의 P2 목록) — 그 면책이 advanced/**를 덮고 90개 파일의 플랫폼은 덮지 않는다. 그래서 P1이다.

권고 — 셋 중 하나. (a) backend.websocket 플랫폼을 조립하는 @Configuration(또는 AutoConfiguration.imports 진입점)을 추가하고 세 안전 장치를 그 안에서 호출한다. (b) 그 플랫폼을 graphql처럼 modelled 등급으로 CLAUDE.md에 공시한다. (c) 제거한다. 지금은 셋 다 아니며, 특히 WebSocketPlatformSettings@ConfigurationPropertiesScan에 걸려 바인딩만 되는 상태가 (a)를 이미 절반 시사한다.

4.2 P3/기록 — origin 허용목록이 두 네임스페이스에 중복 선언돼 있다

§3.3. 실제 적용은 ca-skeleton.websocket.allowed-origins이고, backend.websocket.allowed-origins는 바인딩되지만 읽히지 않는다. 운영자가 후자를 설정하면 "설정했는데 적용되지 않는" 상태가 되고, 두 기본값이 다르므로(빈 집합 대 http://localhost:3000) 어느 쪽을 설정했는지에 따라 결과가 정반대다.


Sub-scope 02 — protocol + codec + handshake + servlet + webflux (29 files, main 23 + test 6)

근거 evidence/raw/220-inbound-websocket-protocol-probes.txt

5. 무엇을 하는 코드인가

와이어 계약이다. WebSocketMessageCatalog(100) + WebSocketMessageDescriptor + WebSocketMessageType(main_other=16) + WebSocketMessageFamily + WebSocketSchemaVersion + WebSocketEnvelope(150) + WebSocketProtocolProfile(101) + WebSocketCatalogFingerprint(64)가 메시지 카탈로그와 봉투를, StrictWebSocketJsonCodec(145)이 엄격한 JSON 디코딩을, WebSocketWireTypeManifest가 고정 표현을 담는다.

핸드셰이크는 HandshakeAdmissionPipeline(145) + HandshakeDecision + HandshakeRequest, 전송은 PlatformWebSocketHandler(205) + ServletFrameSink(45) / ReactiveFrameSink(82) + WebSocketDataBufferLifecycle(106) + WebSocketDataBufferPolicy.

ReactiveFrameSink는 main·test 참조가 모두 0인 유일한 파일이다.

6. Negative-space probes

6.1 (8.1) 도달성

23개 main 파일 중 @Configuration/@Component가 하나도 없고, 이 leaf의 유일한 @Configuration(stomp/WebSocketConfig)이 이 패키지들을 참조하지 않는다. §4.1의 90개 플랫폼 파일 중 23개다.

PlatformWebSocketHandler(205)가 이 sub-scope의 중심이고 main 참조 0 · test 1이다 — 이 leaf가 Spring의 WebSocketHandler로 등록할 핸들러를 갖고 있으면서 등록하지 않는다.

6.2 (8.2) 조건 형제 비교 — 두 전송의 프레임 싱크

ServletFrameSink(45, main_other=1)와 ReactiveFrameSink(82, main_other=0 · test=0). 서블릿 쪽은 최소한 다른 main 파일이 참조하고, 리액티브 쪽은 참조가 없다. §4.1의 WebSocketStackExclusivity가 탐지하려던 상황(리액티브 엔드포인트를 선언했는데 서블릿으로 뜨는 배포)에서 실제로 무엇이 죽는지를 이 비대칭이 보여준다.

6.3 (8.3)·(8.4) 중복·드리프트 — 없음

카탈로그·봉투·코덱이 각각 한 벌이고, WebSocketCatalogFingerprint가 카탈로그 변경을 지문으로 고정한다.

7. Findings

7.1 P3/기록 — ReactiveFrameSink는 테스트조차 없다

23개 파일 중 유일하게 main·test 참조가 모두 0이다. 리액티브 전송이 이 leaf에서 도달 불가라는 사실(§6.2)의 가장 뚜렷한 표시다.

나머지는 §4.1에 포함된다 — 개별 결함이 아니라 90개 플랫폼 파일이 조립되지 않는다는 하나의 사실이다.


Sub-scope 03 — handler + inbound + outbound + session + lifecycle + ordering (30 files, main 21 + test 9)

근거 evidence/raw/221-inbound-websocket-session-probes.txt

8. 무엇을 하는 코드인가

세션 수명주기와 아웃바운드 전달의 기계다.

OutboundQueue(189) + SerializedOutboundWriter(188) + OutboundPriority + OutboundDelivery + OutboundEnqueueResult + GlobalBufferBudget(89) + OutboundQueueSnapshot가 우선순위 큐와 직렬 쓰기, 전역 버퍼 예산을 담는다. 백프레셔 모델이다 — CLAUDE.md가 "backpressure ... are P2 and are not claimed"로 면책한 항목 중 하나가 여기 있다.

WebSocketSessionRegistry(163) · CloseOrchestration(138) · HeartbeatPolicy(65)가 세션 등록과 종료 협상을, WebSocketCorrelationRegistry(162) + LateResponseTombstone(108)이 요청-응답 상관과 늦은 응답 처리를, FragmentAssembler(117)가 프레임 조립을, ordering 3종(GapDetector 98 · StreamSequencer 54 · OrderingProfile 54)이 순서 보장을 담당한다.

9. Negative-space probes

9.1 (8.1) 도달성

21개 main 파일 중 main 참조 0인 것이 여덟이다 — LateResponseTombstone · WebSocketCorrelationRegistry · WebSocketMessageHandler(참조 0·테스트 0) · WebSocketSessionRegistry · CloseOrchestration · HeartbeatPolicy · GapDetector · OrderingProfile · StreamSequencer.

OutboundQueue·SerializedOutboundWriter·GlobalBufferBudget는 서로를 참조하는 내부 클러스터를 이룬다. 클러스터 전체의 진입점이 PlatformWebSocketHandler(§6.1, 미등록)다.

9.2 (8.4) 문서와의 대조

CLAUDE.md의 면책 목록이 backpressure를 포함하므로 outbound(8)는 공시된 범위 안이다. session·lifecycle·ordering·handler·inbound(13 파일)는 면책 목록에 없다.

10. Findings

10.1 P3/기록 — WebSocketMessageHandler는 참조도 테스트도 없다

39줄 인터페이스이고 구현도 호출자도 없다. PlatformWebSocketHandler가 그 자리를 대신하는지는 코드로 판정되지 않는다.

나머지는 §4.1에 포함된다.


Sub-scope 04 — security + authz + idempotency + budget + error + observability + admin + release (31 files, main 22 + test 9)

근거 evidence/raw/222-inbound-websocket-policy-probes.txt

11. 무엇을 하는 코드인가

플랫폼의 정책 계층. WebSocketOriginPolicy(116) · WebSocketConnectionTicket(77) · WebSocketTicketStore · WebSocketAuthenticationProfile(57) · MessageAuthorizationPolicy(93) · WebSocketConnectionBudget(103, main_other=5) · WebSocketCloseCode(95) · WebSocketClosePolicy(73) · WebSocketFailureCategory(59, main_other=10).

멱등성은 CommandReconciliation(84) + CommittedResultLedger(61) + WebSocketCommandKey + WebSocketCommandOutcome.

관측은 SafeWebSocketLogFields(65) + WebSocketMetricTags(76) — 둘 다 main 참조 0.

release 3종은 WebSocketStableReleaseGate(106) · WebSocketRollingRestartScenario(113) · WebSocketNginxProxyProfile(91).

12. Negative-space probes

12.1 (8.1) 도달성 — 정책의 실제 적용 지점

이 sub-scope에서 실제로 요청 경로에 있는 것은 stomp 패키지가 참조하는 것뿐이다. stomp/WebSocketInboundAuthorizationInterceptor(53)와 stomp/AuthenticatedHandshakeInterceptor(34)가 WebSocketConfig에 등록되고, 그 둘은 stomp/WebSocketProperties를 쓴다.

security(4) · authz(1) · idempotency(4) · budget(1)의 플랫폼 정책 타입은 stomp가 참조하지 않는다. 즉 인증 프로파일 · 티켓 · origin 정책 · 메시지 권한 · 연결 예산 · 명령 멱등성이 모두 요청 경로 밖이다.

12.2 (8.2) 조건 형제 비교 — 두 개의 인바운드 권한
stomp/WebSocketInboundAuthorizationInterceptor (53) security/MessageAuthorizationPolicy (93)
등록 WebSocketConfig.configureClientInboundChannel 없음
근거 stomp/WebSocketProperties 플랫폼 정책 모델
범위 STOMP 목적지 권한 메시지 종류별 권한

CLAUDE.md의 Inbound policy 절이 전자를 규정한다. 후자는 문서에 없다.

12.3 (8.4) 카운트 — WebSocketFailureCategory

error 패키지의 WebSocketFailureCategory(main_other=10)가 이 sub-scope에서 가장 널리 참조되는 타입이고, WebSocketErrorMessage(75, main 참조 0)와 WebSocketErrorTransport가 그것을 전송으로 옮긴다. 실제 STOMP 오류는 stomp/SafeStompSubProtocolErrorHandler(32)가 만든다 — 세 번째 오류 형식이다.

13. Findings

13.1 P2 — 연결 티켓·origin 정책·메시지 권한·연결 예산이 요청 경로 밖이고, 그중 일부는 STOMP 어댑터가 다른 방식으로 대체한다

§12.1·§12.2. 실제 배포에서 적용되는 보안은 stomp 패키지의 두 인터셉터이고, 그것은 CLAUDE.md가 서술하는 범위다("HTTP-handshake principal enforcement and client-inbound STOMP destination authorization").

플랫폼 정책 계층(22 파일)이 그보다 넓은 모델을 담는다 — 연결 티켓(핸드셰이크 전 발급), 인증 프로파일, origin 정책 116줄, 메시지 종류별 권한, 연결당 예산, 명령 멱등성 원장. 어느 것도 적용되지 않는다.

노출은 아니다 — 대체 경로가 더 좁을 뿐 존재하며, CLAUDE.md가 그 좁은 범위를 정확히 서술한다. 기록하는 것은 두 보안 모델이 한 leaf에 공존하고 넓은 쪽이 꺼져 있다는 사실이며, §4.1의 부분집합이다.

13.2 P3/기록 — 오류 형식이 셋이다

WebSocketErrorMessage(플랫폼, 미배선) · SafeStompSubProtocolErrorHandler(STOMP, 배선) · WebSocketErrorTransport(전송 추상, 미배선). 실제 클라이언트가 받는 것은 두 번째 하나다.


Sub-scope 05 — stomp (13 files, main 8 + test 5)

근거 evidence/raw/223-inbound-websocket-stomp-probes.txt

14. 무엇을 하는 코드인가 — 이 모듈에서 실제로 동작하는 부분

여덟 개 파일이 CLAUDE.md가 서술하는 모듈 전체다.

// stomp/WebSocketConfig.java
@Configuration
@EnableWebSocketMessageBroker
@EnableConfigurationProperties(WebSocketProperties.class)
@ConditionalOnProperty(prefix = "ca-skeleton.websocket", name = "enabled", havingValue = "true")
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
  registerStompEndpoints:   registry.setErrorHandler(errorHandler);
                            registry.addEndpoint(properties.getEndpoint())
                                    .setAllowedOriginPatterns(properties.allowedOriginPatterns())
                                    .addInterceptors(handshakeInterceptor)
                                    .withSockJS();
  configureClientInboundChannel: registration.interceptors(inboundAuthorization);
  configureMessageBroker:   registry.enableSimpleBroker("/topic");
                            registry.setApplicationDestinationPrefixes("/app");
  @Bean LiveEventStompBroadcaster(SimpMessagingTemplate, WebSocketProperties, List<LiveEventProjector<?>>)
}

LiveEventStompBroadcaster(139)가 도메인 이벤트를 LiveEventProjector 허용목록을 통해서만 밀어낸다 — CLAUDE.md의 Forbidden이 "Raw @DomainEvent payload transmission or reflection-based event serialization"을 금지하고, 프로젝터가 Map<String,String> 경계 투영만 낸다.

WebSocketProperties(117)가 @Validated이고 CLAUDE.md 표의 네 키를 담는다. AuthenticatedHandshakeInterceptor(34)가 핸드셰이크에 비어 있지 않은 Principal을 요구하고, WebSocketInboundAuthorizationInterceptor(53)가 클라이언트 인바운드 채널에서 목적지를 검사하며, SafeStompSubProtocolErrorHandler(32)가 고정 ERROR를 낸다.

15. Negative-space probes

15.1 (8.1) 도달성 — 여덟 파일 전부 배선

WebSocketConfig가 나머지 일곱을 직접 생성하거나 @Bean으로 만든다. 미도달 파일이 없다.

15.2 (8.2) 조건 형제 비교 — 이 어댑터와 플랫폼

§12.2·§13.1. 같은 결정(핸드셰이크 인증 · 인바운드 권한 · 오류 형식 · origin)에 대해 두 구현이 있고 이쪽만 배선된다.

15.3 (8.4) 문서 일치

CLAUDE.md의 Responsibility 다섯 줄 · Typed settings 네 키 · Inbound policy · Event projection contract · Evidence 절이 이 여덟 파일과 정확히 대응한다. 드리프트 없음.

16. Findings — 없음

이 sub-scope는 문서·구현·테스트가 일치한다. WebSocketBoundaryQualificationTest가 랜덤 포트 Tomcat에서 실제 SockJS/STOMP 핸드셰이크 · Origin · principal · 구독 · 서버 투영 push · 애플리케이션 SEND · 브로커 SEND 거부 · ERROR 리댁션을 교차 확인한다.


Sub-scope 06 — advanced/stomp + stomp/rabbit + cluster + resume (54 files, main 41 + test 13)

근거 evidence/raw/224-inbound-websocket-advanced-stomp-probes.txt

17. 무엇을 하는 코드인가

advanced/stomp (17) 은 STOMP 정책 계층이다 — StompProfile(118, main_other=4) · StompDestinationCatalog(129) · StompAuthorizationPolicy(93) · StompSecurityInterceptor(96) · StompAckPolicy(79) · StompAckMode(76) · StompBrokerExclusivity(74) · StompRefusal · StompEvidence.

StompBrokerExclusivity(74)가 §2의 WebSocketStackExclusivity를 인용하며 같은 원칙을 브로커에 적용한다 — "a runtime that silently half-applies is worse than one that will not [start]".

advanced/stomp/rabbit (7) 은 외부 브로커 릴레이 — RabbitBrokerRelayConfiguration(72, @Configuration) · RabbitBrokerRelayProfile(78) · MultiNodeUserDestination(98) · UserDestinationPolicy(50) · UserDestinationRouting(42) · UserSessionLocation.

advanced/cluster (9) 는 다중 노드 — PortBackedExternalSessionIndex(108) · MessagingFanoutAdapter(102) · FanoutDeduplicator(100) · FanoutEnvelope(75) · ExternalSessionIndex · ExternalSessionSummary.

advanced/resume (8) 은 재개 — ResumeTokenCodec(217, 이 sub-scope에서 가장 큰 파일) · ReplayEventMapper(132) · ResumeCoordinator(111) · ResumeDecision(87) · ResumeTokenKeyRing(87) · ResumeTokenPayload(83) · ReplayCursor(64).

18. Negative-space probes

18.1 (8.1) 도달성 — 두 @Configuration이 실제로 무엇을 만드는가
@Configuration 게이트 @Bean leaf 내부 import
advanced/stomp/StompConfiguration (52) app.websocket-platform.advanced.stomp 0 없음
advanced/stomp/StompDefaultsConfiguration (58) 같음 4 없음(프레임워크 빈만)
advanced/stomp/rabbit/RabbitBrokerRelayConfiguration (72) ...advanced.stomp.relay 3 advanced.stomp, core
advanced/sockjs/SockJsConfiguration (103) ...advanced.sockjs 1 advanced.compression, core, security

StompConfiguration@Bean이 하나도 없고 leaf 타입을 import하지도 않는다 — 프레임워크 설정만 조정하는 WebSocketMessageBrokerConfigurer 계열로 보인다.

즉 41개 파일 중 RabbitBrokerRelayConfiguration이 참조하는 advanced.stomp·core 일부만 조립 가능하고, cluster(9)와 resume(8)은 어떤 @Configuration도 참조하지 않는다.

18.2 (8.4) 문서와의 대조 — 이 sub-scope는 명시적으로 면책돼 있다

CLAUDE.md Evidence 절:

"Broker relay, multi-node/durable delivery, rollback-safe publication, replay/resume, backpressure, and a versioned domain projection catalog are P2 and are not claimed."

  • broker relay → advanced/stomp/rabbit (7)
  • multi-node/durable delivery → advanced/cluster (9)
  • replay/resume → advanced/resume (8)

24개 파일이 면책 목록에 정확히 대응한다. advanced/stomp(17)는 목록에 없지만 STOMP 정책이므로 "not claimed" 범위로 읽는 것이 자연스럽다.

18.3 (8.2) 조건 형제 비교 — 재개 토큰 서명

ResumeTokenCodec(217) + ResumeTokenKeyRing(87)이 서명된 재개 토큰을 만든다. 이 조합은 graphql §24.1의 HmacGraphQlCursorCodec + GraphQlCursorKeyRing과 같은 형태다. 차이는 이쪽에는 그 키를 요구하는 시작 검증기가 없다는 것 — 즉 "키를 요구하고 서명하지 않는" 잘못된 확인 신호가 없다. 면책 목록에 replay/resume이 있으므로 문서·코드·검증이 일치한다.

19. Findings — 없음

41개 파일이 CLAUDE.md의 면책 범위 안에 있고, 조립되지 않는다는 사실이 문서와 일치한다. §4.1의 P1은 이 sub-scope를 포함하지 않는다.


Sub-scope 07 — advanced/ 잔여 (41 files, main 30 + test 11)

근거 evidence/raw/225-inbound-websocket-advanced-rest-probes.txt

20. 무엇을 하는 코드인가

advanced/presence(4) · advanced/codec(3) + cbor(3) + protobuf(4) · advanced/sockjs(3) · advanced/graphql(3) · advanced/compression(3) · advanced/client(3) · advanced/release(1) · advanced/http2(1) · advanced/http3(1) · advanced(1).

WebSocketAdvancedCapability(95)가 능력 카탈로그이고 propertyName()"backend.websocket.advanced." 접두사를 만든다 — §0에서 확인한 세 네임스페이스 중 아무 @Configuration도 읽지 않는 그 접두사다. 실제 Advanced 게이트는 app.websocket-platform.advanced.*를 쓴다.

바이너리 코덱이 두 벌 있다 — WebSocketCborCodec(166) + CborCodecProfile + DuplicateKeyPolicy, WebSocketProtobufCodec(159) + ProtobufCodecProfile + DescriptorCompatibility + DescriptorCompatibilityGate(79). 공통 상위는 BinaryCodecProfile(44) + WebSocketBinaryCodecBackend(64) + SchemaParity(105).

advanced/graphql(3)은 GraphQL-over-WebSocket 브리지(GraphQlTransportBridge 163 · GraphQlTransportBridgePolicy 71 · GraphQlCloseCode 52) — graphql leaf의 advanced/websocket(§8, modelled)과 짝을 이루는 반대편이다.

21. Negative-space probes

21.1 (8.1) 도달성

30개 중 @ConfigurationSockJsConfiguration(103) 하나다. 그것이 advanced.compression · core · security를 import하므로 압축 정책과 core 일부가 그 경로로 도달 가능하다. 나머지 27개는 어떤 설정도 참조하지 않는다.

21.2 (8.2) 조건 형제 비교 — 능력 접두사가 둘이다
출처 문자열 소비
advanced/WebSocketAdvancedCapability:76 backend.websocket.advanced.<name> 없음
@Configuration@ConditionalOnProperty app.websocket-platform.advanced.<name> 있음

graphql §36.2(VirtualThreadProfile.propertyName()이 실제 게이트와 다른 이름을 반환)와 같은 형태다. §22.1.

21.3 (8.3) 중복 메커니즘 — 승격 게이트

advanced/release/AdvancedPromotionGate(120)와 release/WebSocketStableReleaseGate(106, §11)가 각각 Advanced 승격과 Stable 릴리스를 판정한다. 둘 다 main 참조 0이고 테스트만 있다 — 릴리스 시점 도구이므로 런타임 미배선이 정상이다.

22. Findings

22.1 P3 — 능력 프로퍼티 이름을 만드는 코드와 실제 게이트가 다른 접두사를 쓴다

§21.2. WebSocketAdvancedCapability.propertyName()이 반환하는 backend.websocket.advanced.*를 읽는 @ConditionalOnProperty가 없다. 운영자가 그 메서드가 알려 주는 키를 설정하면 아무 일도 일어나지 않고, 실제로 능력을 켜는 키는 app.websocket-platform.advanced.*다.

backend.websocket 네임스페이스 전체가 소비자를 갖지 않는다는 §4.1의 부분집합이다.


Sub-scope 08 — testkit + 대체 소스셋 3종 (18 files)

근거 evidence/raw/226-inbound-websocket-testkit-probes.txt

23. 무엇을 하는 코드인가

testkit(7)이 testkit/arch · testkit/fault · testkit/runtime 세 갈래로 계약과 하네스를 담는다.

대체 소스셋 셋이 각각 하나의 IT를 든다:

brokerRelayTest/.../advanced/stomp/StompBrokerContractTest.java
jettyWebSocketTest/.../runtime/JettyWebSocketRuntimeIT.java
nginxWebSocketTest/.../proxy/NginxWebSocketContractIT.java + NginxWebSocketHarness.java
nginxWebSocketTest/resources/nginx/nginx.conf + nginx-no-upgrade.conf

build.gradle이 네 개 레인을 등록한다 — websocketNginxTest · websocketBrokerRelayTest · websocketAdvancedTest · websocketJettyTest.

nginx-no-upgrade.conf가 특히 의미 있다 — 프록시가 Upgrade 헤더를 전달하지 않는 설정을 별도 파일로 두고 그 경우의 계약을 확인한다. WebSocket 배포에서 가장 흔한 운영 실패다.

24. Negative-space probes

24.1 (8.1)·(8.2) 레인이 무엇을 인증하는가

세 IT가 각각 실제 Jetty · 실제 Nginx 컨테이너 · 실제 브로커를 상대로 돈다. 그런데 그 IT들이 세우는 애플리케이션이 무엇인지가 핵심이다 — stomp/WebSocketConfig(배선됨)인가, PlatformWebSocketHandler(미배선)인가.

JettyWebSocketRuntimeITruntime 패키지에 있고 testkit/runtime이 그것을 받친다. inbound-web §48.1에서 확인한 형태("픽스처가 조립하고 레인이 픽스처를 인증한다")가 여기서도 성립하는지는 그 픽스처가 무엇을 등록하는지에 달려 있다.

24.2 (8.4) 레인과 문서

CLAUDE.md Evidence 절이 인용하는 것은 WebSocketBoundaryQualificationTest(랜덤 포트 Tomcat) 하나이고, 네 개 커스텀 레인은 언급되지 않는다.

25. Findings

25.1 P3/기록 — 네 개 커스텀 레인이 CLAUDE.md의 증거 절에 없다

§24.2. 증거로 인용되는 것은 기본 test 레인의 자격 테스트 하나뿐이고, Jetty·Nginx·브로커 릴레이·Advanced 네 레인은 문서에 없다. 그 레인들이 인증하는 것이 면책된 P2 항목(브로커 릴레이)과 미배선 플랫폼(runtime)이므로, 문서가 그것을 증거로 들지 않는 것은 일관되다 — 다만 레인의 존재 자체가 기록되지 않는다.


26. 모듈 종합 — adapter-inbound-websocket

26.1 커버리지 원장 정산

8개 sub-scope, 253 / 253 FULL_READ · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0.

26.2 발견 종합 — P2 2건 · P3 5건 (§4.1은 분석 후 P1 → P2로 하향; §26.6 참조)

심각도 § 발견
P2 4.1 backend.websocket 플랫폼(약 90개 main 파일)에 조립 지점이 없고 모듈 SSOT 문서에 존재하지 않는다 — 세 안전 장치(WebSocketPlatformStartupValidator 125 · WebSocketStackExclusivity 78 · WebSocketPlatformSettings의 safe-default 규약)가 전부 호출자 0
P2 13.1 연결 티켓·origin 정책·메시지 권한·연결 예산·명령 멱등성이 요청 경로 밖이고, 좁은 STOMP 인터셉터 둘이 대체한다
P3 22.1 능력 프로퍼티 이름 생성기와 실제 게이트가 다른 접두사(backend.websocket.advanced.*app.websocket-platform.advanced.*)
P3 4.2 origin 허용목록이 두 네임스페이스에 중복 선언되고 기본값이 정반대(빈 집합 대 http://localhost:3000)
P3 7.1 · 10.1 · 25.1 ReactiveFrameSink 참조·테스트 0 · WebSocketMessageHandler 참조·테스트 0 · 커스텀 레인 4종이 문서 증거 절에 없음

26.3 이 모듈의 성격 — 부분 공시

169개 main 파일이 세 덩어리로 나뉘고 문서가 그중 하나만 서술한다:

 8 파일  stomp/                       CLAUDE.md 전체가 이것을 서술 · 배선됨 · 랜덤포트 Tomcat 자격 테스트
71 파일  advanced/**                  Evidence 절이 "not claimed"로 일부 면책 · 4개 @Configuration이 부분 조립
90 파일  플랫폼 (backend.websocket)    문서 없음 · @Configuration 0 · 안전 장치 셋 전부 미호출

면책은 정확하고 불완전하다. "Broker relay, multi-node/durable delivery, rollback-safe publication, replay/resume, backpressure, and a versioned domain projection catalog are P2 and are not claimed" — 이 문장이 advanced/stomp/rabbit(7) · advanced/cluster(9) · advanced/resume(8) · outbound(8)를 정확히 지목한다. 지목하지 않는 것이 90개 플랫폼 파일이고, 그 안에 "unsafe or incoherent 설정을 거부한다"는 시작 검증기와 "리액티브 엔드포인트가 조용히 응답하지 않는 상태"를 탐지하는 스택 배타성 검사가 있다.

세 인바운드 모듈의 공시 스펙트럼:

inbound-web (14) inbound-graphql (16) inbound-websocket (17)
미배선 규모 다수 다수 90 / 169
공시 없음 (README가 반대 서술) 등급표 13행 전수 + 전용 테스트 부분 — 면책 문장 하나가 24 파일을 덮고 90 파일을 덮지 않음
P1 6 0 1

공시의 완성도가 그대로 P1 수에 대응한다.

그리고 이 모듈은 inbound-web §40.1을 이름 붙여 탐지하는 코드를 갖고 있다WebSocketStackExclusivity가 "with both a servlet container and Reactor Netty available it starts the servlet one... serves none of them — no error, no warning"을 서술한다. web에서 29개 파일을 죽인 그 조건을, 이 leaf는 진단 클래스로 만들어 두고 호출하지 않는다.

26.4 완료 게이트

  • denominator 253 / 253 FULL_READ
  • 8개 sub-scope 각각 §8.1~§8.4 수행 — Spring 애노테이션 전수(169 중 7) · 세 네임스페이스 소비자 추적 · 파일 단위 참조 카운트 · CLAUDE.md 면책 목록 대조
  • evidence 218226 생성
  • 거짓 양성 후보 검증 후 기각: advanced/** 미배선(→ Evidence 절이 면책) · stomp 어댑터의 좁은 범위(→ CLAUDE.md가 정확히 그 범위를 서술) · ResumeTokenCodec 키링 미배선(→ graphql §24.1과 달리 키를 요구하는 검증기가 없어 잘못된 확인 신호가 없음)
  • 소스 미변경

26.5 실행 검증

$ ./gradlew :adapter:inbound:websocket:test
BUILD SUCCESSFUL in 17s
GRADLE_EXIT=0

test-results 집계: classes=91 tests=720 failures=0 errors=0 **skipped=0**

build.gradle이 등록하는 네 개 커스텀 레인(websocketNginxTest · websocketBrokerRelayTest · websocketAdvancedTest · websocketJettyTest)은 실행하지 않았다 — Nginx 레인은 Docker 컨테이너를, Jetty 레인은 별도 임베디드 서버를, 브로커 릴레이 레인은 외부 브로커를 요구한다.

이 모듈의 P1(§4.1)은 720개 테스트가 전부 통과하는 상태에서 나왔다. 미배선 플랫폼 90개 파일이 단위 테스트로 덮여 있고, 조립 여부를 묻는 테스트가 없다.

26.6 분석 후 판정 변경 — §4.1 P1 → P2

app-bootstrap(모듈 18) 분석 중 src/config/architecture/modules.jsonruntime_membershipsconditionalTransportTest의 계약 테스트를 읽고 이 모듈의 등급을 낮췄다.

adapter-inbound-websocket  runtime_memberships = []
adapter-inbound-grpc       runtime_memberships = []
adapter-inbound-graphql    runtime_memberships = ["app-bootstrap"]
adapter-inbound-web        runtime_memberships = ["app-bootstrap", "sample-portfolio"]
// ConditionalTransportCompositionContractTest
/** Transports that must not reach any runtime: no membership, and no composition-root edge. */
BUILD_ONLY_TRANSPORTS = { "adapter-inbound-grpc": GrpcServerConfig,
                          "adapter-inbound-websocket": stomp.WebSocketConfig }

"gRPC and WebSocket are build-only — no membership, and nothing may put them on a runtime."

이 leaf는 어떤 출하 런타임에도 올라가지 않는다. 따라서 §4.1의 실패 시나리오("팀이 backend.websocket.enabled=true를 설정하고 엔드포인트가 열리지 않는다")는 현재 출하되는 두 조합(app-bootstrap · sample-portfolio) 어디에서도 발생할 수 없다. 그것이 P1의 조건("지금 틀린 동작")을 충족하지 않게 만든다.

그러나 발견 자체는 남는다. CLAUDE.md가 "A future composition must deliberately add the registered dependency and set ca-skeleton.websocket.enabled=true"로 채택 경로를 명시하므로, 이 leaf는 채택을 전제로 유지된다. 그 채택 시점에 채택자가 마주하는 상태가 §4.1이 서술한 것 — 세 개 네임스페이스, 그중 하나는 소비자 없음, 문서는 8개 파일만 서술 — 이고, 세 안전 장치가 호출되지 않는다는 사실도 그대로다.

ConditionalTransportCompositionContractTest의 다른 문장이 이 상황에 정확히 적용된다 — "class existence is not composition evidence." 이 모듈에는 조립 증거가 없는 클래스가 90개 있고, build-only 등급이 그것을 오늘의 사고에서 면제하되 채택 시점의 부채로 남긴다.

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 126개 (main 122 · test 1 · 기타 3).

src/adapter/inbound/websocket/build.gradle
src/config/architecture/modules.json                    (adapter-inbound-websocket 항목)

main:
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/WebSocketAdvancedCapability.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ExternalSessionIndex.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ExternalSessionSummary.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/FanoutDeduplicator.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/FanoutEnvelope.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/MessagingFanoutAdapter.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/PortBackedExternalSessionIndex.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayCursor.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayEventMapper.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/SchemaParity.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/WebSocketBinaryCodecBackend.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/CborCodecProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/DuplicateKeyPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/WebSocketCborCodec.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/DescriptorCompatibility.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/DescriptorCompatibilityGate.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/ProtobufCodecProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/WebSocketProtobufCodec.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlCloseCode.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridge.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridgePolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeCoordinator.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeDecision.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenCodec.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenKeyRing.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenPayload.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsConfiguration.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAckMode.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAckPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompBrokerExclusivity.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompConfiguration.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDestinationCatalog.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompEvidence.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompRefusal.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompSecurityInterceptor.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/MultiNodeUserDestination.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayConfiguration.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationRouting.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserSessionLocation.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/authz/MessageAuthorizationPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/budget/WebSocketConnectionBudget.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/StrictWebSocketJsonCodec.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/WebSocketWireTypeManifest.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformSettings.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformStartupValidator.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketStackExclusivity.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketActorReference.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionContext.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionId.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointCatalog.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointName.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketNodeId.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketSubprotocolName.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketCloseCode.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketClosePolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketErrorMessage.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketErrorTransport.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketFailureCategory.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketConnectionEvidence.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketInboundEvidence.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketOutboundEvidence.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/LateResponseTombstone.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketCorrelationRegistry.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketMessageHandler.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeAdmissionPipeline.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeDecision.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeRequest.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/CommandReconciliation.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/CommittedResultLedger.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandKey.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandOutcome.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/inbound/FragmentAssembler.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/CloseOrchestration.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/HeartbeatPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModuleBoundary.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModulePurity.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketStableModule.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/observability/SafeWebSocketLogFields.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/observability/WebSocketMetricTags.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/GapDetector.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/OrderingProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/StreamSequencer.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/GlobalBufferBudget.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundDelivery.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundEnqueueResult.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundPriority.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueue.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueueSnapshot.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/SerializedOutboundWriter.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketCatalogFingerprint.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketEnvelope.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageCatalog.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageDescriptor.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageFamily.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageType.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketProtocolProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketSchemaVersion.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketNginxProxyProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketRollingRestartScenario.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketStableReleaseGate.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketAuthenticationProfile.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketConnectionTicket.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketOriginPolicy.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketTicketStore.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/servlet/PlatformWebSocketHandler.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/servlet/ServletFrameSink.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/session/WebSocketSessionRegistry.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/AuthenticatedHandshakeInterceptor.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventProjector.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventStompBroadcaster.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/SafeStompSubProtocolErrorHandler.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketConfig.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketInboundAuthorizationInterceptor.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketProperties.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/ReactiveFrameSink.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferLifecycle.java
  src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferPolicy.java

test:
  src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketBoundaryQualificationTest.java

기타:
  src/build.gradle
  src/config/architecture/modules.json
  src/jettyWebSocketTest/java/dev/caskeleton/adapter/inbound/websocket/runtime/JettyWebSocketRuntimeIT.java

해석되지 않은 인용 (9종) — 외부 타입·문서상 약칭 등:
  evidence/raw/218-inbound-websocket-module-inventory.txt
  evidence/raw/219-inbound-websocket-core-probes.txt
  evidence/raw/220-inbound-websocket-protocol-probes.txt
  evidence/raw/221-inbound-websocket-session-probes.txt
  evidence/raw/222-inbound-websocket-policy-probes.txt
  evidence/raw/223-inbound-websocket-stomp-probes.txt
  evidence/raw/224-inbound-websocket-advanced-stomp-probes.txt
  evidence/raw/225-inbound-websocket-advanced-rest-probes.txt
  evidence/raw/226-inbound-websocket-testkit-probes.txt


A18. app-bootstrap

분석 중에는 18-app-bootstrap.md 파일이었다. 750줄.

app-bootstrap — 코드베이스 분석

SSOT identity — 2026-08-31 재검증

  • registered leaf id: app-bootstrap
  • canonical state analysisFile: §A18 (이 문서) — 이 leaf의 단일 SSOT
  • source path: src/app-bootstrap · Gradle :app-bootstrap
  • registry allowed_dependencies: ["domain-core", "application-core", "adapter-outbound-persistence-jpa", "adapter-outbound-support", "adapter-outbound-messaging", "adapter-outbound-cache-redis", "adapter-outbound-notification", "adapter-outbound-fileserver", "adapter-outbound-httpclient", "adapter-outbound-identifier", "adapter-inbound-web", "shared-contract", "adapter-outbound-persistence-mongo", "adapter-inbound-graphql", "messaging-spring-boot-starter"]
  • registry runtime_memberships: ["app-bootstrap"]
  • coverage ledger: FULL_READ 455 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0
  • 최초 분석 revision a24ece9c → 재검증 revision 21234e38 · 이 리프의 변경 파일 0
  • 재검증 증거: EVD-333(소스 드리프트 0), EVD-334(lane 재실행) — EVD-334의 jq/exit-78 finding과 compose 계약 독립 검증이 이 리프의 것이다

재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.


분석 대상 src/app-bootstrap · revision a24ece9cf797f7ea647e33bf846b115208ed1ba5 분모 455 tracked files (main 157 · test 288 · functionalTest 4 · sampleOffTest 1 · conditionalTransportTest 1 · governance 4) LOC main Java 12,380 · test Java 32,568 (main의 2.6배) 근거 evidence/raw/227-app-bootstrap-module-inventory.txt, 이하 228235

0. 이 모듈의 위치

컴포지션 루트다. 지금까지 분석한 17개 모듈에서 반복해서 물었던 질문 — "이 능력을 누가 조립하는가" — 의 답이 여기 있거나 없다.

조립 표면 전체가 네 개 리소스 파일에 있다:

META-INF/spring/...AutoConfiguration.imports          (6줄)
  bootstrap.autoconfigure.fileserver.FileserverPlatformAutoConfiguration
  bootstrap.autoconfigure.httpclient.HttpClientPlatformAutoConfiguration
  bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration
  bootstrap.autoconfigure.messaging.DisabledMessagingSentinelAutoConfiguration
  bootstrap.notification.NotificationRootAutoConfiguration
  bootstrap.activation.AdapterActivationAutoConfiguration

META-INF/spring.factories
  EnvironmentPostProcessor          = MasterSwitchEnvironmentPostProcessor,
                                      RuntimeEnvironmentProfileValidator,
                                      CapabilityDependencyEnvironmentValidator,
                                      TracingSamplingEnvironmentPostProcessor,
                                      RedisReadinessGroupPostProcessor,
                                      DatabaseReadinessGroupPostProcessor
  SpringBootExceptionReporter       = StartupFailureExceptionReporter
  AutoConfigurationImportFilter     = JpaOffAutoConfigurationImportFilter
  ApplicationListener               = ResolvedProfileLoggingContextListener

META-INF/spring/...ManagementContextConfiguration.imports  (1줄)
  bootstrap.autoconfigure.fileserver.FileserverAdminManagementContextConfiguration

CaSkeletonApplication.java  — @ComponentScan + @ConfigurationPropertiesScan

여섯 개 자동설정 진입점에 web·websocket·grpc가 없다. 그 셋은 컴포넌트 스캔에 의존하며, 모듈 14·17에서 확인했듯 각각 다른 결과를 낳았다.

1. 커버리지 원장

# sub-scope main test 기타 상태
1 governance + resources + CaSkeletonApplication + activation + settings 21 37 4 62 COMPLETE
2 autoconfigure/* (httpclient 16 · fileserver 15 · jpa 10 · persistencejpa 3 · messaging 1) 45 20 65 COMPLETE
3 runtime + runtime/startup + logging + metrics + tracing 49 36 85 COMPLETE
4 notification + outbox + idempotency + messaging + async + concurrency + lock 35 24 59 COMPLETE
5 security + management/security + redis + mongo + authz 7 5 12 COMPLETE
6 test: 아키텍처 규칙 + 위반/허용 픽스처 0 90 90 COMPLETE
7 test: contract 레인 + integration 0 54 54 COMPLETE
8 test: onboarding 픽스처 + 잔여 + 대체 소스셋 0 22 6 28 COMPLETE
TOTAL 157 288 10 455 8 / 8

FULL_READ 455 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0. 분할은 227-...의 패키지 트리에서 기계 계산(중복 0 · 미할당 0).


Sub-scope 01 — governance + CaSkeletonApplication + activation + settings (62 files)

근거 evidence/raw/228-app-bootstrap-activation-probes.txt (file_count=62)

2. 무엇을 하는 코드인가

활성화 모델의 SSOT는 shared-contractMasterSwitch enum이다:

/**
 * The five adapters this skeleton ships behind an explicit switch, and the names that address them.
 *
 * <p>One place knows the names. Spread across conditions as string literals, a rename becomes a
 * silent activation change: the condition stops matching, the adapter stops assembling, and
 * nothing reports it.
 */
public enum MasterSwitch {
  PERSISTENCE_JPA      ("ca-skeleton.persistence-jpa.enabled",          "APP_PERSISTENCE_JPA_ENABLED"),
  PERSISTENCE_MONGO    ("ca-skeleton.persistence-mongo.enabled",        "APP_PERSISTENCE_MONGO_ENABLED"),
  MESSAGING            ("app.messaging.enabled",                       "APP_MESSAGING_ENABLED"),
  NOTIFICATION_PLATFORM("ca-skeleton.notification.platform.enabled",    "APP_NOTIFICATION_PLATFORM_ENABLED"),
  GRAPHQL              ("backend.graphql.enabled",                     "APP_GRAPHQL_ENABLED");

그 enum이 네 개 장치를 구동한다:

장치 하는 일
MasterSwitchEnvironmentPostProcessor (71) 값이 정확히 true/false가 아니면 부팅 거부
CapabilityDependencyValidator (156) 능력 간 의존(예: outbox는 JPA+messaging 필요) 검증
AdapterActivationEndpoint (74) → AdapterActivationReport (34) 해석된 상태를 액추에이터로 발행
MasterSwitchRegistryContractTest enum ↔ docs/registries/env-keys.yamlapplication.yml 삼자 일치

MasterSwitchEnvironmentPostProcessor의 거부 메시지가 이 모듈의 성격을 보여준다:

"%s must be exactly true or false, but was "%s". A value this close to a boolean is a deployment that believes it set the switch; it is rejected rather than read as off."

그리고 원시 값을 읽는 이유도 적혀 있다 — "An adapter that is off has to be able to start next to an environment full of its own malformed configuration — a validator that bound the namespace to check the switch would fail exactly the deployments the switch exists to protect."

AdapterActivationReport는 "설정된 것"이 아니라 "해석된 것"을 낸다:

"A smoke lane that asserts on the environment it passed in is asserting on its own input. This is the application's answer: the profile it settled on, the state each master switch parsed to, and which capabilities are asking for a relational connection."

MasterSwitchRegistryContractTest가 삼자 일치를 enum에서 파생시킨다 — "Deriving every case from MasterSwitch means a sixth adapter cannot be added without this test demanding its row."

3. Negative-space probes — sub-scope 01

3.1 (8.4) 카운트 드리프트 — "다섯 어댑터"와 실제 스위치를 가진 어댑터

MasterSwitch가 다섯을 담는다. 저장소가 실제로 출하하는, 기본 꺼짐이고 운영자가 켜는 인바운드 어댑터 스위치를 전수하면:

스위치 기본 MasterSwitch env-keys 레지스트리 확인 출처
ca-skeleton.persistence-jpa.enabled off
ca-skeleton.persistence-mongo.enabled off
app.messaging.enabled off
ca-skeleton.notification.platform.enabled off
backend.graphql.enabled off
ca-skeleton.grpc.enabled off 아니오 아니오 모듈 15 §2
ca-skeleton.websocket.enabled off 아니오 아니오 모듈 17 §14
backend.websocket.enabled off 아니오 아니오 모듈 17 §0
backend.web.mvc.enabled · backend.web.webflux.enabled on(matchIfMissing=true) 아니오 아니오 모듈 14 §3.2
backend.web.budgets.enabled · app.web-platform.durable-operations.enabled off 아니오 아니오 모듈 14 §15.4 · §19.2
app.websocket-platform.advanced.* (4종) off 아니오 아니오 모듈 17 §0

기계 확인:

$ grep -rn 'ca-skeleton.grpc|ca-skeleton.websocket|backend.web.mvc|backend.websocket' \
      --include=*.java app-bootstrap/src/main app-bootstrap/src/test      -> 0
$ grep -in 'grpc|websocket' docs/registries/env-keys.yaml                  -> 0   (341개 키 등록)
$ grep -in 'grpc|websocket' docs/registries/capabilities.yaml              -> 0
$ grep -n  'grpc|websocket' src/config/architecture/modules.json           -> 등록된 leaf로는 존재

모듈 레지스트리는 두 leaf를 알고, 활성화 모델과 운영자용 env 레지스트리는 모르는 상태다. §4.1.

3.2 (8.1) 도달성 — 여섯 자동설정 진입점이 덮는 범위
진입점 대상 leaf 모듈 분석에서의 결과
FileserverPlatformAutoConfiguration + FileserverStartupConfiguration outbound fileserver + inbound web의 fileserver/** 모듈 14 §38 — 이 leaf에서 유일하게 완전 조립된 하위 트리
HttpClientPlatformAutoConfiguration outbound httpclient 모듈 11
PersistenceJpaRootAutoConfiguration + JpaOffAutoConfigurationImportFilter persistence-jpa 모듈 5
DisabledMessagingSentinelAutoConfiguration messaging 모듈 12
NotificationRootAutoConfiguration outbound notification 모듈 13
AdapterActivationAutoConfiguration 자기 자신(액추에이터)

inbound web · websocket · grpc에 대응하는 진입점이 없다. 그 셋은 컴포넌트 스캔으로만 조립되고, 스캔 경계(AUTO_CONFIGURED_PACKAGES 정규식)가 web의 다섯 패키지를 제외하면서 그것을 넘겨받을 자동설정을 만들지 않은 것이 모듈 14 §8.1이다.

3.3 (8.2) 조건 형제 비교 — 두 종류의 "꺼짐"
방식 꺼졌을 때의 상태
마스터 스위치 + 게이트된 루트 자동설정 jpa · mongo · messaging · notification · graphql 빈 0개 + 설정 바인딩도 안 됨(@EnableConfigurationProperties가 게이트 안쪽)
컴포넌트 스캔 + @ConditionalOnProperty grpc · websocket · web 빈 0개 + 설정은 바인딩됨(@ConfigurationPropertiesScan이 세 패키지를 포함)

CaSkeletonApplication의 javadoc이 첫 번째 방식의 이유를 적는다:

"The asymmetry that existed before — beans gated, settings not — is why a notification settings object bound itself in a deployment whose notification master was off. A capability whose beans are gated but whose settings still bind is gated only where somebody remembered to gate it."

그리고 두 번째 방식이 정확히 그 비대칭이다 — dev.caskeleton.adapter.inbound.grpc · .web · .websocket 세 패키지가 @ConfigurationPropertiesScan 목록에 있다.

3.4 (8.3) 중복 메커니즘 — 세 개의 환경 검증기

MasterSwitchEnvironmentPostProcessor(스위치 값 문법) · RuntimeEnvironmentProfileValidator(93, 프로파일) · CapabilityDependencyEnvironmentValidator(62 → CapabilityDependencyValidator 156, 능력 간 의존). 셋 다 EnvironmentPostProcessor이고 관심사가 다르다. 중복 아님.

4. Sub-scope 01 findings

4.1 — 다섯 어댑터 범위는 런타임 멤버십 레지스트리와 일치한다 (결함 아님)

§3.1의 표를 처음에는 "출하되는 스위치가 다섯보다 많다"는 결함으로 기록했다. 그 판정은 틀렸다. src/config/architecture/modules.jsonruntime_memberships가 결정적이다:

adapter-inbound-web        runtime_memberships = ["app-bootstrap", "sample-portfolio"]
adapter-inbound-graphql    runtime_memberships = ["app-bootstrap"]
adapter-inbound-grpc       runtime_memberships = []
adapter-inbound-websocket  runtime_memberships = []

gRPC와 WebSocket은 build-only leaf다 — 어떤 런타임에도 올라가지 않는다. 그리고 그 사실이 기계로 강제된다:

// conditionalTransportTest/.../ConditionalTransportCompositionContractTest
/** Transports that must not reach any runtime: no membership, and no composition-root edge. */
private static final Map<String, String> BUILD_ONLY_TRANSPORTS =
    Map.of("adapter-inbound-grpc",      "dev.caskeleton.adapter.inbound.grpc.GrpcServerConfig",
           "adapter-inbound-websocket", "dev.caskeleton.adapter.inbound.websocket.stomp.WebSocketConfig");

그 테스트의 javadoc이 세 전송의 등급을 나눈다:

"gRPC and WebSocket are build-only — no membership, and nothing may put them on a runtime. GraphQL is shipped and switch-gated, which is a stronger claim and carries a stronger obligation: the switch has to be the thing that decides."

따라서 두 어댑터가 MasterSwitch·env-keys.yaml·AdapterActivationReport에 없는 것은 누락이 아니라 일관성이다. 런타임에 오르지 않는 어댑터에는 운영자용 활성화 스위치가 필요하지 않다.

남는 것은 backend.web.* 하나다. adapter-inbound-web은 두 런타임에 올라가고(["app-bootstrap","sample-portfolio"]), 그 스위치들 — backend.web.mvc.enabled · backend.web.webflux.enabled(둘 다 matchIfMissing=true, 기본 켜짐) · backend.web.budgets.enabled · app.web-platform.durable-operations.enabled — 은 MasterSwitch에도 env-keys.yaml 341개 키에도 없다. §4.1b.

4.1b P3 — 출하되는 web 어댑터의 스위치가 활성화 모델 밖에 있다

§4.1. adapter-inbound-web은 두 런타임 멤버이므로 build-only 예외에 해당하지 않는다. 그런데 그 네 개 스위치가 조건 안의 문자열 리터럴로만 존재해 MasterSwitch의 javadoc이 경계하는 상태다 — "Spread across conditions as string literals, a rename becomes a silent activation change."

다만 web은 다른 넷과 성질이 다르다. MVC/WebFlux 스위치는 matchIfMissing = true기본 켜짐이므로 "옵션 어댑터"가 아니고, MasterSwitch가 규정하는 "explicit switch" 모델(전부 기본 꺼짐, env-keys.yaml에 행이 있고 삼자 일치 테스트가 강제)에 그대로 넣을 수 없다. 그리고 모듈 14 §8.1이 확인했듯 web의 조립 자체가 미해결이다 — 스캔에서 다섯 패키지를 빼고 넘겨받는 자동설정을 만들지 않은 상태다.

기록하는 것은 순서다: web의 스위치를 활성화 모델에 넣는 것은 모듈 14 §8.1(어떤 자동설정이 무엇을 소유하는가)을 먼저 정한 뒤에 할 수 있는 일이다. 지금은 "무엇을 스위치로 부를지"가 결정되지 않았다.

4.1c P3/기록 — 조건부 전송 게이트가 빨간 채로 방치된 이력이 기록돼 있다

ConditionalTransportCompositionContractTest의 javadoc이 자기 이력을 적는다:

"This test asserted that all three transports had runtime_memberships: [] and that their classes load. Both halves aged badly. ... The test went red the moment that landed and nobody saw it, because this suite runs in conditionalTransportQualification rather than in test — and conditionalTransportQualification is one of the two commands CI runs. A gate that is red in a lane nobody runs locally is a gate that reports whatever the last person to run it saw."

그리고 두 번째 절반에 대한 판정도 남긴다 — "A type resolving proves the jar is on a classpath; it says nothing about whether a composition assembles the transport... class existence is not composition evidence."

이 문장이 모듈 14 §48.1·모듈 17 §4.1과 같은 원칙을 다른 각도에서 말한다. 기록으로 남긴다.

4.2 P3/기록 — 세 인바운드 leaf의 설정이 마스터 스위치 밖에서 바인딩된다

§3.3. CaSkeletonApplication의 javadoc이 "beans gated, settings not"을 과거의 사고로 기록하면서, 그 형태를 grpc·web·websocket 세 패키지에 대해 유지한다. 모듈 15 §4.2에서 확인했듯 grpc에서는 기본값이 전부 유효해 무해하고, 모듈 17 §4.1에서는 backend.websocket 설정이 바인딩만 되고 소비되지 않는 상태를 만든다.


Sub-scope 02 — autoconfigure/* (65 files, main 45 + test 20)

근거 evidence/raw/229-app-bootstrap-autoconfigure-probes.txt (file_count=65)

5. 무엇을 하는 코드인가

다섯 개 능력의 조립 루트. httpclient(16) · fileserver(15) · jpa(10) · persistencejpa(3) · messaging(1).

fileserver(15)가 이 저장소에서 가장 완전한 조립 사례다. 모듈 14 §38에서 확인했듯 FileserverPlatformAutoConfiguration이 URI 매퍼·다운로드 전략·요청 컨텍스트 팩토리를 만들고, FileserverStartupConfiguration:87attestMapping()을 시작 시 호출하며, FileserverAdminManagementContextConfiguration이 관리 평면을 별도 관리 컨텍스트에 등록한다 — CaSkeletonApplication이 그 이유를 적는다: "those routes belong to the management context, and a component scan that also found them would publish the management plane on the public connector — the exposure the separate context exists to remove."

**persistencejpa(3)**는 JpaOffAutoConfigurationImportFilter를 포함한다 — graphql의 GraphQlOffAutoConfigurationImportFilter(모듈 16 §3.2)와 같은 형태로, 프레임워크 자동설정을 후보 집합에서 제거해 "빈은 없는데 프레임워크가 서비스하는" 상태를 막는다. DataSourceRequirementMasterSwitch를 직접 읽어 어떤 능력이 관계형 연결을 요구하는지 계산한다.

**messaging(1)**은 DisabledMessagingSentinelAutoConfiguration 하나다 — 꺼진 메시징을 사용하려 할 때 조용한 no-op 대신 실패를 내는 센티널.

6. Negative-space probes

6.1 (8.1) 도달성

45개 main 파일 중 다른 main 파일이 참조하지 않는 것은 전부 @Configuration/@AutoConfiguration 루트이며, 이는 .imports가 진입점으로 등록하므로 정상이다. 고아 파일 없음.

6.2 (8.2) 조건 형제 비교 — 두 off 필터

JpaOffAutoConfigurationImportFilter(app-bootstrap)와 GraphQlOffAutoConfigurationImportFilter(graphql leaf). 같은 문제를 같은 방식으로 푼다. 차이는 위치다 — JPA 것은 컴포지션 루트에, GraphQL 것은 leaf에 있다. 둘 다 spring.factories에 등록돼 있고 동작한다.

web·websocket에는 대응물이 없다. 그 둘은 프레임워크가 발행하는 엔드포인트를 갖지 않으므로(자체 @Configuration이 등록) 필요하지 않다.

6.3 (8.4) 카운트 — .imports 여섯 줄과 다섯 능력

.imports의 여섯 항목 중 다섯이 능력 루트이고 하나(AdapterActivationAutoConfiguration)가 자기 액추에이터다. MasterSwitch의 다섯과 일치한다 — 단 PERSISTENCE_MONGO.imports에 루트가 없고 컴포넌트 스캔 제외 정규식(adapter\.outbound\.mongo\..*)으로만 관리된다. §7.1.

7. Findings

7.1 P3/기록 — PERSISTENCE_MONGO만 자동설정 루트가 없다

다섯 마스터 스위치 중 넷은 .imports에 게이트된 루트를 갖는다(jpa · messaging 센티널 · notification · graphql은 자기 leaf에). Mongo는 AUTO_CONFIGURED_PACKAGES 정규식의 adapter\.outbound\.mongo\..* 제외만 있고 그것을 넘겨받는 루트가 .imports에 없다.

모듈 14 §8.1이 정확히 그 형태의 결함이었다(제외했는데 넘겨받지 않음). Mongo에서 같은 상태인지는 이 sub-scope의 파일만으로는 판정되지 않는다 — outbound mongo leaf(모듈 6)가 자기 .imports를 갖는지 확인이 필요하고, 그 모듈은 이미 COMPLETE로 분석됐으나 이 관점에서 재확인하지 않았다. 기록으로 남긴다.


Sub-scope 03 — runtime + runtime/startup + logging + metrics + tracing (85 files, main 49 + test 36)

근거 evidence/raw/230-app-bootstrap-runtime-probes.txt (file_count=85)

8. 무엇을 하는 코드인가 — 이 저장소에서 시작 검증이 실제로 도는 곳

열두 개 검증기가 전부 배선돼 있다:

StartupSafetyValidator (103)              main_refs=3    RuntimeSafetyConfig
SecretSourceValidator (182)               main_refs=3    SecretSourceConfig
RequiredEnvironmentValidator (49)         main_refs=1
HikariPoolConstraintValidator             main_refs=2
JpaSchemaSafetyValidator                  main_refs=1
OpenInViewSafetyValidator                 main_refs=1
PersistenceVendorProdSafetyValidator      main_refs=2
PostgreSqlTransportSecurityValidator      main_refs=2
RedisActivationValidator                  main_refs=2
RuntimeNumericBoundsValidator             main_refs=1
FlywayProdSafetyValidator                 main_refs=1
MigrationStartupRunner (53)               main_refs=1    MigrationStartupConfig

RuntimeSafetyConfig(@Configuration, @Bean 7개)가 "Wires the runtime-safety startup fail-fast validators into the running application"을 한다.

StartupSafetyValidatorSmartInitializingSingleton인 이유가 적혀 있다 — "the check runs once after every singleton is instantiated but before the context finishes refreshing; a violation throws so the context refuses to start."

시작 실패의 어휘가 구조화돼 있다. StartupFailures(75)가 단일 발생원이고 "Every factory method emits the structured failure log (with the startup.phase / error.code / error.category fields) before returning the exception to throw." StartupErrorCode · StartupPhase · StartupFailureLogState가 그 어휘이고, StartupFailureExceptionReporterSpringBootExceptionReporter로 등록돼 컨텍스트가 없는 시점의 실패도 같은 형식으로 낸다. 종료 코드까지 규정한다(STARTUP_VALIDATION_FAILED = exit 78, MigrationFailedException = exit 70).

logging(8)은 logback을 통해 배선된다. MetricsAsyncAppender · SamplingTurboFilter · SecretMaskingMessageConverter · SecretMaskingJsonGeneratorDecorator · StartupFailureSpringBootLogFilter 다섯이 Java 참조가 0인데, logback-spring.xml이 클래스 이름으로 등록한다(:56 · :59 · :62 · :91 · :124 · :137 · :148). ResolvedProfileLoggingContextListenerspring.factoriesApplicationListener이고, 그 이유가 주석에 있다 — "An ApplicationListener, not a bean: this event fires before there is a context to hold one."

9. Negative-space probes

9.1 (8.1) 도달성 — main 참조 0인 파일의 전수 분류

49개 main 파일 중 다른 main 파일이 참조하지 않는 것은 열둘이고, 전부 설명된다:

분류 파일 등록 경로
@Configuration 루트 RuntimeSafetyConfig · SecretSourceConfig · SecretSourceSettings · MigrationStartupConfig · TracingConfig · PseudonymizationConfig 컴포넌트 스캔
logback 컴포넌트 MetricsAsyncAppender · SamplingTurboFilter · SecretMaskingMessageConverter · SecretMaskingJsonGeneratorDecorator · StartupFailureSpringBootLogFilter logback-spring.xml
spring.factories ResolvedProfileLoggingContextListener · TracingSamplingEnvironmentPostProcessor spring.factories

고아 없음. 지금까지 분석한 17개 모듈 중 이 성질을 가진 것은 grpc(모듈 15)와 여기뿐이다.

9.2 (8.2) 조건 형제 비교 — 시작 검증기의 운명
모듈 검증기 배선
app-bootstrap 12종 전부
outbound fileserver ← app-bootstrap NginxInternalUriMapper.attestMapping() 예 (모듈 14 §39.1)
inbound web WebPlatformStartupValidator (62) 아니오 (모듈 14 §44.2)
inbound websocket WebSocketPlatformStartupValidator (125) · WebSocketStackExclusivity (78) 아니오 (모듈 17 §4.1)
inbound graphql GraphQlPlatformStartupValidator 예 (leaf 자기 자동설정)

검증기가 도는지 여부는 그 능력에 컴포지션 루트의 자동설정이 있는지와 정확히 일치한다.

9.3 (8.3)·(8.4) 중복·드리프트 — 없음

StartupFailures가 단일 발생원이고 종료 코드·단계·카테고리가 한 어휘에 모여 있다. 검증기 12종이 서로 다른 불변식을 보며 겹치지 않는다.

10. Findings — 없음


Sub-scope 04 — notification + outbox + idempotency + messaging + async + concurrency + lock (59 files, main 35 + test 24)

근거 evidence/raw/231-app-bootstrap-capability-probes.txt (file_count=59)

11. 무엇을 하는 코드인가

능력별 조립. notification(10) + notification/observation(4)이 모듈 13에서 확인한 배선의 출처다 — NotificationRootAutoConfiguration(.imports 등록, @ConditionalOnProperty) · NotificationPlatformWorkerConfig(모듈 13 §12.1이 확인한 배경 작업자 3종 등록) · NotificationPlatformProviderConfig · NotificationPlatformDispatchConfig · NotificationPlatformObservabilityConfig.

outbox(6) · idempotency(5) · messaging(1, KafkaSenderConfig 113) · async(5) · concurrency(2) · lock(2)이 나머지다.

12. Negative-space probes

12.1 (8.1) 도달성

35개 main 파일 중 main 참조 0인 것은 전부 @Configuration 루트다(IdempotencyConfig · PostgreSqlIdempotencyProviderConfig · AsyncExecutorConfig · DomainContextConfig · DistributedLockConfig · KafkaSenderConfig · NotificationRootAutoConfiguration). 고아 없음.

12.2 (8.2) 조건 형제 비교 — 모듈 13의 미배선 항목이 여기 있는가

모듈 13에서 P2로 기록한 다섯 중 컴포지션 루트가 고칠 수 있는 것을 확인했다:

모듈 13 발견 app-bootstrap에 배선 지점이 있는가
§17.1 AccessContext 감사 미기록 아니오 — leaf 내부 문제(복호화 경로가 감사 포트를 부르지 않음)
§25.1 Web Push SSRF 가드 미적용 아니오 — leaf 내부(WebPushSubscriptionValue의 private 사본)
§29.1 FCM ambiguous 미번역 아니오 — leaf 내부(FcmBatchCoordinator)
§12.1 배경 작업자 3종 예 — 배선됨(NotificationPlatformWorkerConfig:57·90·119)

즉 컴포지션 루트가 할 수 있는 부분은 이미 돼 있고, 남은 것은 leaf 내부의 회로다.

13. Findings — 없음


Sub-scope 05 — security + management/security + redis + mongo + authz (12 files, main 7 + test 5)

근거 evidence/raw/232-app-bootstrap-security-probes.txt (file_count=12)

14. 무엇을 하는 코드인가

security/AuthenticationModeCompositionConfig(48)가 JWT/Redis-세션 두 인증 모드의 조립을 결정하고, management/security(1)가 관리 평면 보안을, redis/RedisCapabilityConfig(376 — 이 모듈에서 가장 큰 단일 파일)가 Redis 능력 전체를, mongo/MongoPlatformHealthConfig(37)가 Mongo 헬스를, authz(1)가 권한 배선을 담당한다.

15. Negative-space probes

15.1 (8.1)·(8.2) 도달성과 게이트

7개 main 파일 전부 @Configuration이고 @ConditionalOnProperty/@ConditionalOnBean으로 게이트된다. MongoPlatformHealthConfig@ConditionalOnBean + @ConditionalOnMissingBean + @ConditionalOnProperty 셋을 함께 쓰는데, 자동설정 안에서의 @ConditionalOnBean은 Boot가 평가 순서를 통제하므로 모듈 14 §7.1이 경고한 컴포넌트 스캔 상의 위험이 없다.

16. Findings — 없음


Sub-scope 06 — test: 아키텍처 규칙 + 위반/허용 픽스처 (90 files)

근거 evidence/raw/233-app-bootstrap-architecture-probes.txt (file_count=90)

17. 무엇을 하는 코드인가

아키텍처 규칙 14종이 여기서 프로덕션 트리에 적용된다:

ArchitectureViolationFixtureTest   CleanArchitectureTest              ContractSuiteIsolationArchTest
DisabledAdapterArchitectureTest    DomainFeatureOnboardingContractTest FileserverUseCaseContractDeviationTest
GraphQlInboundOwnershipBoundaryTest JpaProductionArchitectureTest      MongoRawAccessBoundaryTest
NamingConventionTest               NotificationArchitectureTest        ProductionClassImportOption
TestTaxonomyArchitectureTest       WebProductionArchitectureTest

WebProductionArchitectureTest가 모듈 14 §46에서 확인한 WebArchitectureRules.all() 7규칙을 프로덕션 트리에 적용하는 지점이고, NotificationArchitectureTest·JpaProductionArchitectureTest·MongoRawAccessBoundaryTest·GraphQlInboundOwnershipBoundaryTest가 각 leaf의 규칙 팩에 대해 같은 일을 한다.

위반/허용 픽스처가 76개다. architecture/violations/**(application 14 · domain 5 · streaming 4 · boundary 3 · adapter 5 · slice 2 · fixtureleak 4 · contractisolation 4 · shared 1 · serialization 1 …)와 architecture/allowed/**(streaming 2 · slice 2 · contractisolation 4 · application 2). 규칙마다 거부되어야 할 합성 트리통과해야 할 합성 트리를 함께 두는 형태다.

모듈 14 §3.4에서 확인한 WebModuleBoundaryTest의 부정 픽스처 넷과 같은 원칙이 저장소 규모로 적용돼 있다 — "A boundary test that has never been shown to fail is indistinguishable from one that scans the wrong directory."

18. Negative-space probes

18.1 (8.1)·(8.4) 규칙과 픽스처의 대응

14개 규칙 클래스에 76개 픽스처가 붙는다. ArchitectureViolationFixtureTest가 위반 픽스처들이 실제로 거부되는지를 확인하는 메타 테스트이고, TestTaxonomyArchitectureTest가 테스트 자체의 분류 규약을 강제한다.

ProductionClassImportOption이 스캔 대상을 프로덕션 클래스로 한정하는 공유 옵션 — 픽스처가 규칙에 잡히지 않도록 하는 장치다. architecture/violations/fixtureleak(4)는 그 장치가 새는 경우를 픽스처로 고정한다.

18.2 (8.3) 중복 메커니즘 — 규칙 팩의 위치

leaf가 규칙 팩을 소유하고(WebArchitectureRules in web testkit) 컴포지션 루트가 적용하는 형태와, 컴포지션 루트가 규칙을 직접 쓰는 형태(CleanArchitectureTest)가 공존한다. 전자는 leaf가 자기 규칙을 소유하고 후자는 저장소 전역 규칙이므로 역할이 다르다. 중복 아님.

19. Findings — 없음


Sub-scope 07 — test: contract 레인 + integration (54 files)

근거 evidence/raw/234-app-bootstrap-contract-probes.txt (file_count=54)

20. 무엇을 하는 코드인가

30개 계약 테스트가 저장소 전역 불변식을 확인한다. 성격별로:

성격
레지스트리 일치 ErrorCodeRegistryMappingTest · SecretsClassificationRegistryTest · ContractRegistrySchemaGovernanceTest · RepositoryAccessCapabilityRegistryTest · RegistryGovernanceCatalog
관측·로그 계약 StructuredLogFieldContractTest · MetricsAlertingContractTest · DistributedTracingContractTest · SqlLoggingForbiddenContractTest · PiiTokenBodyForbiddenContractTest
보안 표면 ActuatorSecurityHttpTest · ManagementActuatorSecurityContractTest · ProfileSeparationContractTest
활성화·조건부 OptionalAdapterConditionalExecutionContractTest · ConditionalTransportQualificationContractTest · RedisOptionalityContractTest · EnvProfileMatrixContractTest
실패 분류 PersistenceFailureMappingContractTest · LockFailureClassificationContractTest · LockAcquisitionTimeoutClassificationContractTest · BackgroundJobErrorCodeContractTest
운영 RunbookCoverageContractTest · ContainerRuntimeOomContractTest · SecretReloadContractTest · DeveloperExperienceContractTest
자기 검사 ContractSuiteCompletenessTest

ContractSuiteCompletenessTest가 존재한다는 사실이 이 sub-scope의 성격을 말한다 — 계약 스위트 자체의 완전성을 검사하는 계약이다.

21. Negative-space probes

21.1 (8.2) 조건 형제 비교 — 세 전송의 조건부 실행 증거

ConditionalTransportQualificationContractTest(test 레인)와 ConditionalTransportCompositionContractTest(별도 conditionalTransportTest 소스셋)와 ConditionalTransportEvidenceFunctionalTest(functionalTest 소스셋) 셋이 같은 축을 다른 레인에서 본다.

두 번째 것의 javadoc이 §4.1c에서 인용한 자기 이력을 담는다 — 레인 분리가 만든 사각지대를 스스로 기록한 사례다.

21.2 (8.1) 도달성 — 레지스트리 계약이 실제 레지스트리 파일을 읽는가

MasterSwitchRegistryContractTestdocs/registries/env-keys.yamlsrc/app-bootstrap/src/main/resources/application.yml을 실제로 읽어 대조한다(§2). ErrorCodeRegistryMappingTest·SecretsClassificationRegistryTestdocs/registries/ 아래 파일을 읽는다. 파일 기반 SSOT가 테스트로 고정돼 있다.

22. Findings — 없음


Sub-scope 08 — test: onboarding 픽스처 + 잔여 + 대체 소스셋 (28 files)

근거 evidence/raw/235-app-bootstrap-testrest-probes.txt (file_count=28)

23. 무엇을 하는 코드인가

dev.caskeleton.onboarding.**(14 파일)이 저장소 밖 패키지의 합성 feature다 — onboarding/domain/feature · application/usecase · application/query · application/port · adapter/inbound/web/dto · adapter/outbound. DomainFeatureOnboardingContractTest(§17)가 이것을 대상으로 "새 feature를 추가하는 절차"가 규칙을 만족하는지 확인한다.

대체 소스셋 셋:

conditionalTransportTest/  ConditionalTransportCompositionContractTest      (§4.1)
functionalTest/            BuildVerificationPurityContractTest
                           ConditionalTransportEvidenceFunctionalTest
                           RuntimeMembershipFunctionalTest
                           StrictQualificationTestConventionFunctionalTest
sampleOffTest/             SampleOffClasspathContractTest

RuntimeMembershipFunctionalTestmodules.jsonruntime_memberships를 실제 부트 JAR에 대해 확인하는 것으로 보이며, 그것이 §4.1의 build-only 판정을 뒷받침하는 두 번째 증거다.

SampleOffClasspathContractTest(sampleOffTest 소스셋)는 샘플 모듈을 제외한 클래스패스에서의 계약을 확인한다 — 스켈레톤이 샘플 없이 성립하는지의 증거다.

24. Findings — 없음


25. 모듈 종합 — app-bootstrap

25.1 커버리지 원장 정산

8개 sub-scope, 455 / 455 FULL_READ · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0.

25.2 발견 종합 — P1 0건 · P2 0건 · P3 3건 · 기록 2건

심각도 § 발견
P3 4.1b 출하되는 adapter-inbound-web의 스위치 넷이 활성화 모델(MasterSwitch·env-keys.yaml·액추에이터·삼자 일치 테스트) 밖에 있다. 다만 둘은 기본 켜짐이라 "explicit switch" 모델에 그대로 넣을 수 없고, 모듈 14 §8.1(조립 소유권)을 먼저 정해야 한다
P3 26.1 ComposeMergeCharacterizationTestdocker compose 부재는 skip하고 jq 부재는 실패로 처리한다 — 같은 테스트의 도구 가드가 불완전하다
P3/기록 7.1 다섯 마스터 스위치 중 PERSISTENCE_MONGO.imports에 게이트된 루트가 없고 스캔 제외 정규식만 있다
기록 4.1c 조건부 전송 게이트가 별도 레인에서만 돌아 빨간 채로 방치된 이력이 그 테스트 javadoc에 기록돼 있다
기록 4.2 세 인바운드 leaf(grpc·web·websocket)의 설정이 @ConfigurationPropertiesScan으로 마스터 스위치 밖에서 바인딩된다

25.3 이 모듈의 성격 — 조립이 실제로 일어나는 곳

main 157 파일에 고아가 없다. main 참조가 0인 파일은 전부 설명된다 — @Configuration/@AutoConfiguration 루트(.imports 또는 컴포넌트 스캔), logback 컴포넌트(logback-spring.xml이 클래스 이름으로 등록), spring.factories 항목(EnvironmentPostProcessor 6 · SpringBootExceptionReporter · AutoConfigurationImportFilter · ApplicationListener).

지금까지 분석한 18개 모듈 중 이 성질을 가진 것은 grpc(모듈 15)와 여기뿐이다.

시작 검증기 12종이 전부 배선돼 있다(§8). 그리고 그 사실이 다른 모듈의 결과를 설명한다:

검증기 배선 대응 자동설정
app-bootstrap runtime/* 12종 RuntimeSafetyConfig
fileserver attestMapping() FileserverStartupConfiguration (app-bootstrap)
graphql GraphQlPlatformStartupValidator leaf 자기 자동설정
web WebPlatformStartupValidator 아니오 없음
websocket WebSocketPlatformStartupValidator · WebSocketStackExclusivity 아니오 없음

검증기가 도는지 여부가 그 능력에 자동설정 루트가 있는지와 정확히 일치한다. 그것이 이 저장소의 조립 결함을 설명하는 단일 규칙이다.

25.4 이 모듈이 나머지 분석을 교정했다

app-bootstrap을 읽고 두 가지가 바뀌었다:

  1. modules.jsonruntime_memberships가 build-only 등급을 규정한다 — grpc·websocket은 [], graphql은 ["app-bootstrap"], web은 ["app-bootstrap","sample-portfolio"]. 그리고 ConditionalTransportCompositionContractTest가 "nothing may put them on a runtime"을 기계로 강제한다. 이것으로 모듈 17 §4.1을 P1 → P2로 하향했다(모듈 17 §26.6).
  2. MasterSwitch 다섯의 범위가 자의적이지 않다 — 런타임에 오르는 어댑터 중 옵션인 것들이다. 처음에 "출하 스위치가 다섯보다 많다"로 기록한 것을 §4.1에서 철회했다.

두 교정 모두 컴포지션 루트를 읽지 않고는 도달할 수 없었다. 조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다는 것이 이 모듈이 남긴 방법론적 결론이다.

26. 실행 검증

$ ./gradlew :app-bootstrap:test
BUILD FAILED in 1m 47s
GRADLE_EXIT=1

test-results 집계: classes=169 tests=1016 failures=1 errors=0 skipped=4
FAILED: ComposeMergeCharacterizationTest.every lane renders exactly the services its contract names
26.1 P3 — 실패는 환경 원인이며, 그 테스트의 도구 가드가 불완전하다
org.opentest4j.AssertionFailedError: [verify-compose-profile-contracts.sh said:
jq is required
]
expected: 0 but was: 78
   at ComposeMergeCharacterizationTest.everyLaneMatchesItsContract(ComposeMergeCharacterizationTest.java:62)

$ which jq   ->   NO_JQ

저장소 결함이 아니다. 분석 컨테이너에 jq가 없고, 위임된 스크립트가 그것을 요구하며 exit 78로 정직하게 실패한다.

기록하는 것은 가드의 비대칭이다:

@Test
void everyLaneMatchesItsContract() {
  Assumptions.assumeTrue(dockerComposeIsAvailable(), "docker compose is not on this machine");
  ProcessResult result = run(List.of("./scripts/verify-compose-profile-contracts.sh"));
  assertThat(result.exitCode()).isZero();
}

docker compose 부재는 skip으로 처리하고, 같은 스크립트가 요구하는 jq 부재는 실패로 나타난다. 도구가 없는 기계에서 이 테스트는 계약 위반처럼 읽히는 실패를 낸다 — 메시지가 "jq is required"라 원인은 드러나지만, 이미 skip을 선택한 테스트가 두 번째 도구에 대해서만 다르게 행동한다.

이 저장소는 반대 방향의 원칙도 갖고 있다 — webNginxProxyTest가 "A lane that quietly passes when the container runtime is missing is a lane that has been certifying nothing since whenever Docker last broke"로 skip을 거부한다. 두 원칙 중 어느 쪽을 택하든 한 테스트 안에서 도구별로 갈리지는 않는 편이 낫다.

나머지 1,015개 테스트는 통과한다(failures=1, errors=0, skipped=4).

26.2 재검증 — 그 레인 계약이 실제로 성립하는지 독립 경로로 확인했다 (2026-08-31)

§26.1이 남긴 실질적 공백은 "그래서 15개 레인이 계약과 맞는가"가 확인되지 않은 채였다는 점이다. 실패 원인이 환경이라는 판정은 옳지만, 그 판정은 계약의 성립 여부를 말해 주지 않는다.

jq는 설치하지 않았다 — 사용자 기계의 시스템 변경이다. 대신 verify-compose-profile-contracts.sh의 6개 검사를 파이썬으로 이식해 같은 docker compose config 렌더링 위에서 돌렸다. 아무것도 기동하지 않는 정적 검사다(EVD-334).

compose version 2.40.3+ds1-0ubuntu1~24.04.1 >= 2.24.4
off-local: app @ local                     off-dev: app @ dev
off-prod: app @ prod                       local-jpa: app,db @ local
local-mongo: app,mongo,mongo-rs-init @ local
local-messaging: app,kafka @ local         local-messaging-outbox: app,db,kafka @ local
local-notification-ingest / -serving / -handoff  … @ local
local-graphql: app,auth-smoke,graphql-smoke,keycloak @ local
shared-infra-local / shared-infra-dev / prod-smoke … 
all-adapters: app,auth-smoke,db,db-migrate-capabilities,db-promote-capabilities,
              graphql-smoke,kafka,keycloak,mailpit,mongo,mongo-rs-init,notification-smoke @ local

PORT RESULT: all 15 lanes match src/config/runtime/compose-profile-contracts.json   (exit 0)

이식본이 검사한 것은 원본과 같은 여섯이다 — ①compose 버전 하한 ②레인별 정확한 서비스 집합 ③렌더된 SPRING_PROFILES_ACTIVE ④병합 모델의 마운트 대상 중복 ⑤서비스-역할 분할과 --wait 대상 정합 ⑥Keycloak realm의 밑줄 키.

이것이 무엇을 말하고 무엇을 말하지 않는가. 계약 위반의 증거가 없다는 것이지, 원본 스크립트가 통과한다는 것이 아니다. 그 구별은 테스트 자신의 주석이 이미 경계한 것이다 — "a test that re-derived the same checks in Java would be a second opinion that can agree with the contract while the script disagrees." jq가 있는 기계에서 원본 스크립트로 확인하는 것이 여전히 정본이며, 여기서 얻은 것은 그 확인이 이루어질 때까지의 잠정 근거다.

exit 78은 sysexits.h의 EX_CONFIG — "설정이 없어 확인할 수 없음"이고, 스크립트 끝의 exit 1이 "계약이 틀렸음"이다. §26.1이 기록한 가드 비대칭의 비용이 정확히 이것이다: 두 코드가 하나의 실패로 뭉개지면서, 아무것도 검사되지 않은 상태가 계약 위반으로 보고됐다.

27. 완료 게이트

  • denominator 455 / 455 FULL_READ
  • 8개 sub-scope 각각 §8.1~§8.4 수행 — 조립 표면 전수(.imports 6 · spring.factories 4종 · 관리 컨텍스트 1) · MasterSwitch 다섯과 런타임 멤버십 대조 · main 참조 0 파일의 등록 경로 전수 분류 · 시작 검증기 12종 배선 확인
  • evidence 227235 생성
  • 실행 검증: tests=1016 failures=1(환경 원인) errors=0 skipped=4
  • 분석 중 판정 철회 1건(§4.1 — 활성화 모델의 다섯 어댑터 범위는 결함이 아님) · 타 모듈 판정 하향 1건(모듈 17 §4.1 P1 → P2)
  • 소스 미변경
  • 2026-08-31 재검증 — 리프 소스 변경 0(EVD-333), lane 재실행 tests=1016 failures=1(동일 환경 원인), 그리고 §26.1이 남긴 공백을 독립 경로로 메움 — 15개 레인 전부 계약 일치(§26.2, EVD-334)

Source anchors

이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 93개 (main 49 · test 38 · 기타 6).

src/app-bootstrap/build.gradle
src/config/architecture/modules.json                    (app-bootstrap 항목)

main:
  src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java
  src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationAutoConfiguration.java
  src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationEndpoint.java
  src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationReport.java
  src/main/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyEnvironmentValidator.java
  src/main/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyValidator.java
  src/main/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessor.java
  src/main/java/dev/caskeleton/bootstrap/activation/RuntimeEnvironmentProfileValidator.java
  src/main/java/dev/caskeleton/bootstrap/async/AsyncExecutorConfig.java
  src/main/java/dev/caskeleton/bootstrap/autoconfigure/fileserver/FileserverAdminManagementContextConfiguration.java
  src/main/java/dev/caskeleton/bootstrap/autoconfigure/fileserver/FileserverPlatformAutoConfiguration.java
  src/main/java/dev/caskeleton/bootstrap/autoconfigure/fileserver/FileserverStartupConfiguration.java
  src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java
  src/main/java/dev/caskeleton/bootstrap/autoconfigure/messaging/DisabledMessagingSentinelAutoConfiguration.java
  src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/DataSourceRequirement.java
  src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/JpaOffAutoConfigurationImportFilter.java
  src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/PersistenceJpaRootAutoConfiguration.java
  src/main/java/dev/caskeleton/bootstrap/concurrency/DomainContextConfig.java
  src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyConfig.java
  src/main/java/dev/caskeleton/bootstrap/idempotency/PostgreSqlIdempotencyProviderConfig.java
  src/main/java/dev/caskeleton/bootstrap/lock/DistributedLockConfig.java
  src/main/java/dev/caskeleton/bootstrap/logging/MetricsAsyncAppender.java
  src/main/java/dev/caskeleton/bootstrap/logging/PseudonymizationConfig.java
  src/main/java/dev/caskeleton/bootstrap/logging/ResolvedProfileLoggingContextListener.java
  src/main/java/dev/caskeleton/bootstrap/logging/SamplingTurboFilter.java
  src/main/java/dev/caskeleton/bootstrap/logging/SecretMaskingJsonGeneratorDecorator.java
  src/main/java/dev/caskeleton/bootstrap/logging/SecretMaskingMessageConverter.java
  src/main/java/dev/caskeleton/bootstrap/logging/StartupFailureSpringBootLogFilter.java
  src/main/java/dev/caskeleton/bootstrap/messaging/KafkaSenderConfig.java
  src/main/java/dev/caskeleton/bootstrap/mongo/MongoPlatformHealthConfig.java
  src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformDispatchConfig.java
  src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformObservabilityConfig.java
  src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformProviderConfig.java
  src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformWorkerConfig.java
  src/main/java/dev/caskeleton/bootstrap/notification/NotificationRootAutoConfiguration.java
  src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java
  src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceConfig.java
  src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceSettings.java
  src/main/java/dev/caskeleton/bootstrap/runtime/StartupSafetyValidator.java
  src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationFailedException.java
  src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupConfig.java
  src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupErrorCode.java
  src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureExceptionReporter.java
  src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureLogState.java
  src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailures.java
  src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupPhase.java
  src/main/java/dev/caskeleton/bootstrap/tracing/TracingConfig.java
  src/main/java/dev/caskeleton/bootstrap/tracing/TracingSamplingEnvironmentPostProcessor.java
  src/main/resources/application.yml

test:
  src/test/java/dev/caskeleton/bootstrap/activation/MasterSwitchRegistryContractTest.java
  src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java
  src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
  src/test/java/dev/caskeleton/bootstrap/architecture/DomainFeatureOnboardingContractTest.java
  src/test/java/dev/caskeleton/bootstrap/architecture/GraphQlInboundOwnershipBoundaryTest.java
  src/test/java/dev/caskeleton/bootstrap/architecture/JpaProductionArchitectureTest.java
  src/test/java/dev/caskeleton/bootstrap/architecture/MongoRawAccessBoundaryTest.java
  src/test/java/dev/caskeleton/bootstrap/architecture/NotificationArchitectureTest.java
  src/test/java/dev/caskeleton/bootstrap/architecture/ProductionClassImportOption.java
  src/test/java/dev/caskeleton/bootstrap/architecture/TestTaxonomyArchitectureTest.java
  src/test/java/dev/caskeleton/bootstrap/architecture/WebProductionArchitectureTest.java
  src/test/java/dev/caskeleton/bootstrap/compose/ComposeMergeCharacterizationTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/ActuatorSecurityHttpTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/BackgroundJobErrorCodeContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/ContainerRuntimeOomContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/ContractRegistrySchemaGovernanceTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/ContractSuiteCompletenessTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/DistributedTracingContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/EnvProfileMatrixContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/ErrorCodeRegistryMappingTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/LockAcquisitionTimeoutClassificationContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/LockFailureClassificationContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/ManagementActuatorSecurityContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/MetricsAlertingContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/PersistenceFailureMappingContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/PiiTokenBodyForbiddenContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/RedisOptionalityContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/RegistryGovernanceCatalog.java
  src/test/java/dev/caskeleton/bootstrap/contract/RepositoryAccessCapabilityRegistryTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/RunbookCoverageContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/SecretReloadContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/SecretsClassificationRegistryTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/SqlLoggingForbiddenContractTest.java
  src/test/java/dev/caskeleton/bootstrap/contract/StructuredLogFieldContractTest.java

기타:
  docs/registries/env-keys.yaml
  src/conditionalTransportTest/java/dev/caskeleton/bootstrap/transport/ConditionalTransportCompositionContractTest.java
  src/config/architecture/modules.json
  src/functionalTest/java/dev/caskeleton/bootstrap/contract/ConditionalTransportEvidenceFunctionalTest.java
  src/functionalTest/java/dev/caskeleton/bootstrap/contract/RuntimeMembershipFunctionalTest.java
  src/sampleOffTest/java/dev/caskeleton/bootstrap/contract/SampleOffClasspathContractTest.java

해석되지 않은 인용 (11종) — 외부 타입·문서상 약칭 등:
  env-keys.yaml
  modules.json
  evidence/raw/227-app-bootstrap-module-inventory.txt
  evidence/raw/228-app-bootstrap-activation-probes.txt
  evidence/raw/229-app-bootstrap-autoconfigure-probes.txt
  evidence/raw/230-app-bootstrap-runtime-probes.txt
  evidence/raw/231-app-bootstrap-capability-probes.txt
  evidence/raw/232-app-bootstrap-security-probes.txt
  evidence/raw/233-app-bootstrap-architecture-probes.txt
  evidence/raw/234-app-bootstrap-contract-probes.txt
  evidence/raw/235-app-bootstrap-testrest-probes.txt


A19. messaging-platform

분석 중에는 19-messaging-platform.md 파일이었다. 1,299줄.

19. messaging platform family — 25 leaf 통합 분석

  • 분석 대상 리비전: a24ece9cf797f7ea647e33bf846b115208ed1ba5
  • 범위: src/messaging/**src/config/architecture/modules.json에 등록된 25개 leaf
  • 분모: git 추적 파일 550개 (main Java 364 / 약 29,900 LOC · test Java 119 · 나머지 build/resource/doc)
  • 가족 로컬 권위 문서: src/messaging/CLAUDE.md (132줄)
  • 테스트 레인: 25개 leaf의 :test 전량 — BUILD SUCCESSFUL (2m 27s, 105 actionable tasks, exit 0)

0. 이 문서가 다른 모듈 문서와 다른 점

앞선 18개 모듈은 대부분 leaf 하나가 문서 하나였다. messaging은 25개 leaf가 하나의 플랫폼을 이루고, leaf 경계를 넘는 계약(capability 선언 → 프로파일 검증 → 인증 증거)이 이 가족의 실제 설계다. 그래서 분모를 25개 합산으로 잡고, 7개 sub-scope로 나눠 읽되 발견은 leaf가 아니라 계약 단위로 기록한다.

그리고 이 가족은 지금까지 분석한 어떤 모듈보다 자기 결함을 먼저 적어 놓았다. src/messaging/CLAUDE.md가 미해결 P0(MSG-015)을 명시하고, StartupProfileValidation의 javadoc이 자기가 고친 결함을 서술하며, KafkaMessagingTransport는 자기가 되돌린 capability 플래그의 이력을 남긴다. 따라서 이 문서의 일차 작업은 공개된 주장을 검증하는 것이고, 그 검증 결과가 "주장대로 성립한다"인 항목은 결함이 아니라 검증된 사실로 기록한다.


1. 분모와 커버리지 원장

1.1 등록 leaf 25개 — 파일 수 · 의존 폭 · 런타임 멤버십

modules.json에서 gradle_path:messaging:으로 시작하는 leaf를 전량 열거하고, 각 source_path에 대해 git ls-files로 파일 수를 세었다.

# leaf 파일 허용 의존 runtime_memberships
1 messaging-core-api 95 0 app-bootstrap
2 messaging-kafka 61 7 app-bootstrap
3 messaging-spring-boot-starter 44 17 app-bootstrap
4 messaging-rabbit 33 7 app-bootstrap
5 messaging-policy 32 2 app-bootstrap
6 messaging-admin-api 28 2 app-bootstrap
7 messaging-outbox-jdbc-postgresql 28 5 app-bootstrap
8 messaging-testkit 23 4 []
9 messaging-admin-runtime 20 6 app-bootstrap
10 messaging-transport-spi 19 3 app-bootstrap
11 messaging-observability 17 1 app-bootstrap
12 messaging-security 17 1 app-bootstrap
13 messaging-reliability-api 15 1 app-bootstrap
14 messaging-schema-api 15 1 app-bootstrap
15 messaging-inbox-jdbc-postgresql 13 2 app-bootstrap
16 messaging-pulsar-experimental 12 7 []
17 messaging-runtime-core 12 6 app-bootstrap
18 messaging-claim-check 11 2 app-bootstrap
19 messaging-nats-experimental 11 7 []
20 messaging-spring-cloud-stream-bridge 10 3 []
21 messaging-schema-avro 8 2 []
22 messaging-kafka-share-experimental 7 4 []
23 messaging-cloudevents 6 2 app-bootstrap
24 messaging-schema-json 6 2 app-bootstrap
25 messaging-schema-protobuf 6 2 []
합계 549

분모 550 = leaf 549 + 가족 공통 파일 1개(src/messaging/CLAUDE.md). 미배정 0.

출하 18 / build-only 7. runtime_memberships가 비어 있는 7개는 모듈 18(app-bootstrap)에서 확인한 것과 같은 등급이다 — 빌드되고 테스트되지만 어떤 런타임에도 오르지 않는다:

messaging-testkit
messaging-pulsar-experimental
messaging-nats-experimental
messaging-spring-cloud-stream-bridge
messaging-schema-avro
messaging-kafka-share-experimental
messaging-schema-protobuf

이 구분이 이 문서 전체에서 심각도를 가르는 축이다. 모듈 17(websocket) §26.6에서 확립한 원칙 — 런타임에 오르지 않는 leaf의 미조립은 오늘의 사고가 아니라 채택 시점의 부채 — 를 그대로 적용한다. 반대로 app-bootstrap 멤버인 18개 leaf의 미조립은 지금 출하되는 아티팩트의 결함이다.

주의할 이름 두 가지. 등록된 것은 messaging-inbox-jdbc-postgresql / messaging-outbox-jdbc-postgresql이고, CloudEvents leaf의 id는 messaging-cloudevents(schema 접두사 없음)다. 디스크에 messaging-inbox-jpa / messaging-outbox-jpa 두 디렉터리가 보이지만 git 추적 파일이 0개이고 modules.json에도 없으므로 분모에서 제외한다.

1.1b sub-scope 분할
# sub-scope leaf 파일
01 core contracts core-api · transport-spi · reliability-api · runtime-core 141
02 schema schema-api · schema-json · schema-avro · schema-protobuf · cloudevents 41
03 policy · security · observability policy · security · observability 66
04 brokers kafka · rabbit · kafka-share-experimental · pulsar-experimental · nats-experimental · spring-cloud-stream-bridge 134
05 reliability stores outbox-jdbc-postgresql · inbox-jdbc-postgresql · claim-check 52
06 admin admin-api · admin-runtime 48
07 assembly · testkit · 가족 거버넌스 spring-boot-starter · testkit · CLAUDE.md 68
합계 25 leaf 550
1.2 커버리지 원장 (sub-scope 01)
leaf main test main LOC 등급
messaging-core-api 85 8 3,948 FULL_READ (핵심 계약 40 파일) / STRUCTURAL_ONLY (나머지)
messaging-transport-spi 13 4 776 FULL_READ
messaging-reliability-api 13 0 817 FULL_READ
messaging-runtime-core 6 4 787 FULL_READ

messaging-core-api의 패키지 분포:

26  api/error          (예외 24 + FailureCategory + FailureDescriptor)
17  api/publish
13  api/delivery
12  api                (값 타입: MessageId, CorrelationId, CausationId, TenantContext, TraceContext, UuidV7, WireSafeText, ...)
 7  api/destination
 5  api/settlement
 5  api/header

증거: evidence/raw/243-messaging-core-contracts-inventory.txt


2. 이 가족이 공개한 주장과 검증 결과

src/messaging/CLAUDE.md는 Stable 승격 조건 5개를 명시한다. 그중 코드로 검증 가능한 3개를 먼저 확인했다.

2.1 MSG-022 — "예외 타입을 문자열로 판별하지 않는다" → 성립
grep -rn "getClass().getName().contains|getSimpleName().contains|getMessage().contains" */src/main
→ 0 matches

25개 leaf 전체 main 소스에서 0건. 증거: 246-messaging-mss022-no-string-matching.txt.

이 규칙은 앞선 모듈들에서 반복해서 깨진 것이다(모듈 11 httpclient, 모듈 13 notification). messaging 가족에서는 지켜진다.

2.2 "NetworkFaultScenario 전 항목에 evidence가 있거나, 없는 항목이 knownGaps로 명시된다" → 성립

시나리오는 5개다:

시나리오 Phase 기대 결과 Kafka 증거
connection-refused BEFORE_TRANSMISSION REJECTED 없음 (knownGap)
connection-cut-after-write AFTER_TRANSMISSION AMBIGUOUS
confirm-timeout AFTER_TRANSMISSION AMBIGUOUS
high-latency (30s) AFTER_TRANSMISSION AMBIGUOUS
settlement-lost DURING_SETTLEMENT REDELIVERED

커밋된 manifest는 4줄, 전부 messaging-kafka / apache/kafka:4.1.0. 빠진 1개는 침묵하지 않는다CertifiedEvidence.knownGaps(adapter)all() - covered파생하고, 테스트가 그 값을 이름과 이유까지 붙여 단언한다:

void aScenarioWithNoLineInTheManifestIsAGapRatherThanAnAbsence() {
  assertThat(CertifiedEvidence.knownGaps("messaging-kafka"))
      .as("a Kafka producer buffers before it learns a connection exists, so this stays unproven")
      .contains(NetworkFaultScenario.CONNECTION_REFUSED);
  assertThat(CertifiedEvidence.knownGaps("messaging-rabbit"))
      .as("no lane runs a fault scenario against RabbitMQ, so every scenario is a gap")
      .containsExactlyElementsOf(NetworkFaultScenario.all());
}

knownGaps가 손으로 유지되는 목록이 아니라 manifest에서 계산된다는 것이 핵심이다. 증거: 244-messaging-fault-scenarios-vs-evidence.txt.

2.3 "게이트는 커밋된 manifest와 이번 실행의 출력을 대조한다" → 성립

messaging-kafka/build.gradleverifyMessagingCertificationEvidence가 실제로 그 대조를 한다:

Set<String> ran = claims(produced.get().asFile)     // build/messaging-certification/...jsonl
Set<String> shipped = claims(committed)             // messaging-testkit/src/main/resources/...jsonl
if (ran != shipped) {
    def unproven = shipped - ran
    def unrecorded = ran - shipped
    throw new GradleException(... "claimed but not produced: ${unproven}" ...)
}
  • 양방향이다 — 손으로 추가한 줄(shipped - ran)도, 기록되지 않은 실행 결과(ran - shipped)도 실패시킨다.
  • gitCommit·observedAt은 정규식으로 제거한 뒤 비교한다. 그래서 커밋된 manifest의 gitCommit: e98b56eb03ec…가 분석 리비전 a24ece9c…보다 오래된 것은 설계상 정상이고 결함이 아니다.
  • outputs.upToDateWhen { false } — "이전 실행에서 서빙될 수 있는 게이트는 그 실행에 대한 증거"라는 주석과 함께 캐시를 막는다.
  • CI(.github/workflows/messaging-certification.yml)가 src/messaging/** PR마다 이 게이트를 돌린다. 레인이 아니라 게이트를 돌리고, 게이트가 레인에 의존한다.
  • 인증 레인만 Docker 가드를 일부러 달지 않았다 — "skip하는 인증 레인은 아무도 띄우지 않은 브로커에 대해 성공을 보고한다". 대신 test에서 태그를 제외해 랩톱 빌드를 깨지 않는다.

증거: 250-messaging-certification-gate-chain.txt.

판정: 이 세 주장은 성립한다. 지금까지 19개 모듈에서 "문서가 주장한 통제가 실제로 회로가 닫힌" 첫 사례다.


3. sub-scope 01 — core contracts (141 파일)

3.1 하나의 publish 경로

DefaultMessagePublisher(runtime-core)가 이 플랫폼의 유일한 publish 경로다. 클래스 javadoc이 자기 존재 이유를 적는다:

"MessagePublisher was an interface with no implementation. The brokers implemented MessagingTransport...; the core auto-configuration built a dead-letter orchestrator and blocking/reactive facades on top of a publisher bean nothing supplied; and the admission controller, access policy, runtime registry and observation existed as beans that no publish ever called. An application that filled the gap with its own publisher would pass a context test while running none of them."

고정된 단계 순서: resolve → authorize → encode → admit → lease → send → normalize. 각 단계의 위치가 근거와 함께 서술된다.

  • destination·access가 encode보다 먼저 — 인가되지 않은 publish가 payload를 직렬화하면 claim-check나 로그가 그 바이트를 들고 있게 된다.
  • encode가 admit보다 먼저 — admission 한도가 바이트 기준이고, 바이트 수는 인코딩 전에 알 수 없다.
  • runtime lease가 send 직전 마지막 — 이미 in-flight 카운트에 잡힌 메시지 밑에서 rotation이 transport를 갈아끼우지 못하게.

그리고 획득한 것은 성공·실패·예외·취소 모든 경로에서 정확히 한 번 반환된다 — "실패 경로에서 새는 permit은 실패 한 번에 하나씩 줄어들다 아무것도 받지 않게 되는 limiter다."

3.2 증거를 먼저 기록하고 결론을 나중에 고른다

PublishEvidence는 record compact constructor로 표현 불가능한 조합을 거부한다:

if (!brokerAccepted && confirmationLevel != ConfirmationLevel.NONE)
    throw new IllegalArgumentException("confirmation level requires broker acceptance: " + ...);
if (transmission == TransmissionEvidence.NOT_TRANSMITTED && brokerAccepted)
    throw new IllegalArgumentException("untransmitted message cannot be broker accepted");

javadoc: "Evidence is recorded before a completion is chosen, not derived from it. That ordering is what lets an operator answer 'could the broker be holding this message?' from a stored result."

ConfirmationLevel(NONE / BROKER_ACK / REPLICATION_OR_PERSISTENCE_ACK)이 요청한 ConfirmationRequirement별도 타입으로 유지되는 이유도 명시돼 있다 — "'we asked for replication and got a leader ack'가 표현 가능하고, 따라서 거부 가능하도록."

그리고 DefaultMessagePublisher의 실패 분류가 이 구분을 지킨다:

지점 completion 근거
access 거부 / encode 실패 REJECTED + notTransmitted() "아무것도 이 프로세스를 떠나지 않았으므로 결과는 확정적이다. ambiguous로 보고하면 어떤 브로커도 보지 못한 메시지에 대해 caller를 reconciliation으로 보낸다."
준비 중 데드라인 소진 REJECTED 아직 전송 전
transport 단계 실패 / 데드라인 AMBIGUOUS + retryable=true "요청이 wire 위에 있었으므로 브로커가 들고 있을 수 있다."

sanitized(Throwable)타입만 남기고 메시지를 버린다 — "드라이버 메시지는 routing key, payload 조각, connection string을 담을 수 있고 FailureDescriptor는 로깅·export되도록 설계됐다." 이것이 §2.1의 MSG-022가 0건인 이유이기도 하다.

3.3 데드라인이 caller의 것이다
private Duration remainingBudget(Duration timeout, long startedAt) {
    return timeout.minus(elapsedSince(startedAt));
}

호출 시점부터 측정한다 — "느린 destination 조회나 큰 encode가 브로커 대기와 같은 예산을 쓴다; transport 호출만 재면 준비에 걸린 시간만큼 총합이 데드라인을 초과한다."

그리고 withDeadlineinFlight.toCompletableFuture().copy().orTimeout(...)복사본에 건다. 만료가 transport 자신의 stage를 완료시키지 않게 해서 어댑터가 자기 in-flight publish의 소유권을 유지한다. javadoc이 이전 상태를 적는다: "transports accepted request.options() and read nothing from it, so the deadline belonged to whatever the broker client happened to be configured with — a Rabbit publish waiting on a confirm that never arrives had no deadline at all."

3.4 P2 — capability 12개 중 main 코드가 읽는 것은 3개, 거부하는 것은 1개

MessagingCapabilities의 클래스 javadoc이 이 record의 계약을 선언한다:

"When a profile asks for something absent here the platform fails loudly — at startup where possible, otherwise with a capability exception — rather than quietly degrading, because a silently weakened guarantee is indistinguishable from a working one until the incident."

12개 플래그 전체에 대해 main/test 참조를 센 결과:

플래그 main test main에서 하는 일
brokerAcknowledgement 0 1
replicationOrPersistenceEvidence 0 0
perMessageSettlement 0 1
batchSettlement 0 0
orderedStream 1 1 DefaultRetryDecisionEngine:49 — 있으면 순서보존 재시도 선택
keyedOrdering 0 3
replay 0 2
delayedDelivery 1 0 DefaultRetryDecisionEngine:64 — 있으면 BROKER_DELAYED 사용
brokerTransaction 0 3
deduplicatedPublish 1 1 DefaultMessagePublisher:250없으면 예외
nativeDeadLetter 0 1
topologyManagement 0 0

(census의 orderedStream main=5 중 4건은 Spring ObjectProvider.orderedStream()으로 이름이 겹친 오탐이다. 실제 플래그 참조는 1건.)

따라서:

  • 12개 중 9개는 모든 브로커 어댑터가 선언하지만 main 코드 어디서도 읽지 않는다.
  • 읽는 3개 중 2개(orderedStream, delayedDelivery)는 거부가 아니라 분기다 — 없으면 재시도 엔진이 조용히 다른 모드를 고른다. 이것이 javadoc이 "quietly degrading"이라 부른 그 동작이다.
  • 거부하는 것은 deduplicatedPublish 하나뿐이다:
private void requireSupportedOptions(DestinationProfile profile, PublishOptions options) {
  if (options.deduplication().isPresent()
      && !transport.capabilities(profile.name()).capabilities().deduplicatedPublish()) {
    throw new MessagingConfigurationException("PUBLISH_DEDUPLICATION_UNSUPPORTED", ...);
  }
}

메서드 이름은 requireSupportedOptions(복수)이지만 검사하는 옵션은 하나다. PublishOptionstimeout · confirmation · deduplication 세 성분이고, 그중 confirmation은 여기서 검사되지 않는다.

실패 시나리오. PublishOptions.defaults()ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK을 기본값으로 쓴다(PublishOptions:43). replicationOrPersistenceEvidence=false인 브로커에 붙은 destination으로 publish하면 — 중앙 경로는 아무것도 거부하지 않고, 어댑터는 요청받은 requirement를 그대로 ConfirmationLevel로 되돌려준다:

// KafkaPublishMapper:95-101, RabbitConfirmCoordinator:265-268 — 같은 식이 두 곳에 있다
ConfirmationLevel level =
    requirement == ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK
        ? ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK
        : ConfirmationLevel.BROKER_ACK;

보고되는 confirmation level은 브로커가 준 것이 아니라 프로파일이 요구한 것이다. Kafka 매퍼의 javadoc이 이 위험을 알고 있고, 그것을 다른 장치에 위임한다 — "the producer is configured with acks=all for any profile asking for replication evidence and the profile guard refuses the combination where it is not." 그 profile guard가 무엇인지는 §3.5와 §4.4에서 확인한다.

ConfirmationLevel의 javadoc이 약속한 "표현 가능하고 따라서 거부 가능"의 거부 쪽 절반이 이 경로에 없다. 두 어댑터가 requirement를 level로 그대로 승격시키는 한, PublishEvidence.confirmed(level)이 담는 값은 관측이 아니라 요청의 반복이다.

증거: 247-messaging-capability-flag-enforcement-census.txt

3.5 P2 — 8개 profile validator 중 조립에서 실행되는 것은 3개

StartupProfileValidation의 javadoc이 이미 한 번 고쳐진 같은 결함을 서술한다:

"The Kafka, RabbitMQ and security validators were all beans and none of them was injected anywhere: the context published a validator per broker and validated nothing. A profile that promises a guarantee its broker cannot give — an exactly-once claim on a non-transactional producer, a quorum ack on a single replica, a plaintext credential on a production listener — then boots cleanly and fails on the first message that depends on it."

수정 방식도 정확하다 — InitializingBean.afterPropertiesSet으로 돌려서 "컨텍스트가 아직 만들어지는 중에 실패하고 원인이 된 프로파일 bean이 스택에 이름으로 남게" 한다. 그리고 프로파일을 ObjectProvider가 아니라 Supplier로 받는다 — 애플리케이션이 선언한 bean과 app.messaging에서 컴파일된 프로파일 두 출처를 모두 보기 위해서다.

그 수정이 적용된 것은 2개다. main에 존재하는 profile validator 8개 전체의 도달성:

validator leaf 출하? 조립 지점 실행되는가
DestinationProfileValidator policy 출하 MessagingCoreAutoConfiguration:134 (validateAll 직접 호출)
KafkaProfileValidator kafka 출하 KafkaMessagingAutoConfiguration:58StartupProfileValidation
RabbitProfileValidator rabbit 출하 RabbitMessagingAutoConfiguration:54StartupProfileValidation
KafkaTransactionProfileValidator kafka (출하) 출하 KafkaMessagingAutoConfiguration:74@Bean 선언만, 주입처 없음
KafkaShareProfileValidator kafka-share build-only KafkaShareGroupRegistrar가 보유 — 그 registrar는 테스트에서만 생성 ✘ (등급 일치)
NatsJetStreamProfileValidator nats build-only 참조가 javadoc 문장 하나뿐 ✘ (등급 일치)
PulsarProfileValidator pulsar build-only 참조 0건 — 테스트조차 없다 ✘ (등급 일치)
BindingProfileValidator scs-bridge build-only 테스트에서만 생성 ✘ (등급 일치)

아래 넷은 build-only leaf에 있다(§1.1). 어떤 런타임에도 오르지 않으므로 조립 지점이 없는 것이 등급과 일치한다 — 모듈 17 §26.6의 원칙대로 오늘의 사고가 아니라 채택 시점의 부채다. 다만 PulsarProfileValidator테스트조차 없어서 다른 셋과도 다르다: 나머지 build-only validator 3개는 최소한 단위 테스트가 그 규칙을 실행한다.

출하되는 leaf에서 조립되지 않은 validator는 KafkaTransactionProfileValidator 하나다. 그래서 이 항목만 P2다.

KafkaTransactionProfileValidator가 가장 날카롭다. 같은 자동설정 클래스 안에서, StartupProfileValidation으로 감싼 kafkaProfileStartupValidation(:56) 바로 아래에 @Bean 선언만 있고(:74) 아무도 주입하지 않는다. 그 validator의 javadoc은 자기 마지막 규칙을 이렇게 설명한다:

"The last rule is the important one. A destination that declares INBOX_TRANSACTIONAL is telling the platform its side effect lives in a database, and a Kafka transaction cannot span that. Allowing both to be configured together would let a team read 'transactional' twice and conclude the whole path is atomic when the two halves can still diverge."

그 규칙을 포함해 5개 규칙(transactional id prefix 필요 / idempotence 필요 / acks=all 필요 / manual commit 필요 / INBOX_TRANSACTIONAL 금지)이 한 번도 실행되지 않는다.

실패 시나리오. Kafka 트랜잭션을 쓰는 destination에 externalSideEffectGuarantee = INBOX_TRANSACTIONAL을 같이 설정한다. 부팅은 깨끗하게 성공한다. 브로커 트랜잭션은 Kafka 쪽만 커밋하고 DB 쪽은 별도 커밋이므로, 두 커밋 사이에 프로세스가 죽으면 갈라진다. 팀은 설정 두 곳에서 "transactional"을 읽었으므로 원자성을 가정하고 보정 로직을 쓰지 않았다. 이것이 validator가 막으려던 바로 그 상황이고, 막는 코드는 존재하며 테스트도 없고 호출도 없다.

그리고 acks=all 규칙이 §3.4가 위임한 그 profile guard다 — Kafka 매퍼의 javadoc이 "the profile guard refuses the combination where it is not"라고 말한 대상. 그 guard가 실행되지 않으므로 §3.4의 위임이 닫히지 않는다.

증거: 248-messaging-profile-validator-reachability.txt, 249-messaging-startup-profile-validation.txt

3.6 P3 — messaging-reliability-api는 main 13파일 · 817 LOC에 테스트가 0개다

sub-scope 01의 네 leaf 중 유일하게 src/test가 없다.

messaging-core-api        main=85  test=8
messaging-transport-spi   main=13  test=4
messaging-reliability-api main=13  test=0     <-
messaging-runtime-core    main=6   test=4

담고 있는 것은 Outbox/Inbox 계약이다 — OutboxRecord, OutboxStatus, OutboxLease, OutboxTransitionResult, OutboxCanonicalMetadata, InboxRecord, InboxResult, InboxRepository, IdempotentMessageHandler, ReliableMessagePublisher, TransactionalMessageAction, ClaimCheckReference, OutboxRepository. 대부분 interface·record·enum이지만 OutboxTransitionResult/OutboxStatus는 상태 전이 규칙을 담을 수 있는 타입이다.

sub-scope 05(messaging-outbox-jdbc·-inbox-jdbc)에서 구현 쪽 테스트가 이 계약을 어디까지 덮는지 확인한다(§7).

3.7 P3/기록 — CertifiedEvidenceTest의 첫 테스트는 이름이 주장하는 것을 증명하지 않는다
@Test
void everyRecordedScenarioIsALineTheCertificationLaneWrote() throws IOException {
  List<String> manifestLines = manifest();
  assertThat(manifestLines).as("the certification lane writes this file; nothing else may").isNotEmpty();
  assertThat(CertifiedEvidence.recorded())
      .as("coverage is read from the manifest, not from a list somebody maintained beside it")
      .containsExactlyElementsOf(manifestLines.stream().map(BrokerCertificationEvidence::fromJson).toList());
}

이 단언은 같은 파일을 두 경로로 읽어 비교한다 — CertifiedEvidence.recorded()가 그 manifest를 파싱한 결과이므로, 증명되는 것은 로더가 충실하다는 것뿐이다. 클래스 javadoc이 스스로 세운 기준("the only thing that can tell them apart is where the values come from — which is what these tests check")을 이 테스트 하나만으로는 충족하지 못한다.

그러나 그 기준을 실제로 지키는 장치는 다른 곳에 있다 — §2.3의 verifyMessagingCertificationEvidence가 실행 산출물과 커밋본을 대조하고 CI가 그것을 돌린다. 따라서 이것은 결함이 아니라 이름과 배치의 문제다. test 레인에서 도는 이 단언이 이름만 보면 출처를 보증하는 것처럼 읽히고, 실제 보증은 Docker가 필요한 별도 레인에 있다. 기록으로 남긴다.


4. sub-scope 02 — schema (41 파일)

leaf 출하? main test 내용
messaging-schema-api 출하 10 3 MessageCodec·MessageCodecRegistry·EncodedMessage·MessageContractKey·SchemaReference·SchemaRegistry·SchemaCompatibility·SchemaCompatibilityValidator·RawBytesMessageCodec·BoundedByteSink
messaging-schema-json 출하 1 3 JacksonMessageCodec
messaging-schema-avro build-only 2 3 AvroMessageCodec·AvroCompatibilityGate
messaging-schema-protobuf build-only 2 1 ProtobufMessageCodec·ProtobufMessageContract
messaging-cloudevents 출하 3 1 CloudEventMapper·DefaultCloudEventMapper·CloudEventExtensions
4.1 검증된 설계 — 인코딩 한도가 보고 기준이 아니라 할당 경계다

BoundedByteSinkOutputStream을 상속해 한도를 넘기는 write에서 실패한다. javadoc이 이전 상태를 적는다:

"Every codec here used to serialize into an unbounded buffer and compare bytes.length to the configured maximum afterwards. That makes the maximum a reporting threshold rather than an allocation bound: a payload whose graph expands to hundreds of megabytes exhausts the heap while being written, and the check that would have rejected it never runs. Under a broker consumer that is a process-wide outage caused by one message."

그리고 ByteArrayOutputStream(Math.min(maxBytes, 8_192)) — "1 GiB 한도가 1 GiB를 미리 잡으면 안 된다."

세 코덱(JSON·Avro·Protobuf) 전부가 이 sink를 쓴다(도달성 확인). 예외 코드는 코덱별로 다르다(PAYLOAD_TOO_LARGE / AVRO_PAYLOAD_TOO_LARGE) — 같은 예외 타입에 코덱 고유 코드를 실어 보내는 방식이고 일관된다.

4.2 검증된 설계 — 기본 코덱을 "먼저 등록된 것"으로 고르지 않는다

RegisteredMessageCodecs.of(defaultCodec, codecs...)는 기본 코덱을 명시 인자로만 받고, 두 가지를 거부한다:

  • raw-bytes 코덱을 기본으로 지정하는 것 — "인코딩을 선언하지 않은 모든 destination이 스키마 검증을 조용히 건너뛰게 된다. 그럴 수 있는 registry는 선언 누락을 검사 생략으로 바꾼다."
  • 한 content type을 두 코덱이 주장하는 것 — "런타임에 해결할 선호가 아니다: 어느 쪽이 이기든 다른 쪽으로 컴파일된 consumer가 wire의 바이트를 읽는 방식을 결정한다."

그리고 기본 계약 카탈로그가 비어 있음이다. 자동설정 javadoc:

"The message contracts ... default to empty, which means no message type can be encoded and every publish is refused at preparation. That is fail-closed on purpose: a codec that accepted an unregistered type would serialise whatever object it was handed and put bytes on a topic that consumers compiled against a different shape will read."

4.3 P2 — 스키마 호환성 검증기는 출하 leaf에 있고, main 코드에서 호출되지 않는다

SchemaCompatibilityValidator(messaging-schema-api, 출하)의 main 참조는 0건이다. 테스트 1개뿐.

이 클래스가 하는 일은 두 가지다:

  1. versionsToCheck(subject) — 호환성 모드에 따라 후보 스키마를 비교해야 할 버전 목록(transitive면 전체 이력, pairwise면 직전 하나)을 돌려준다.
  2. requireProductionMode(subject, destination)NONE_EXPERIMENTAL을 production destination에서 거부한다.

두 번째의 근거가 클래스 javadoc에 있다:

"NONE_EXPERIMENTAL is refused for production destinations. A mode that checks nothing is useful while a message type is being designed and actively dangerous once a retained log exists, because the log outlives every consumer that could still read it."

그리고 그 모드는 설정으로 켤 수 있다. DestinationSettings.Schema:

@DefaultValue("BACKWARD_TRANSITIVE") SchemaCompatibility compatibility,

@DefaultValue가 안전한 값이지만, app.messaging.destinations.<name>.schema.compatibility=NONE_EXPERIMENTAL을 쓰면 그대로 통과한다 — DestinationProfileValidator의 16개 규칙에 스키마 항목이 없고(§3.5), requireProductionMode는 호출되지 않는다.

부수적으로 SchemaRegistry에는 main 구현이 하나도 없다. 유일한 구현은 SchemaCompatibilityValidatorTestFixedRegistry다. 즉 검증기가 읽어야 할 스키마 이력의 출처 자체가 프로덕션에 존재하지 않는다. SchemaCompatibilityValidator를 호출하려 해도 넘길 registry가 없다 — §3.5의 MessageCodecRegistry가 겪었고 RegisteredMessageCodecs로 해결된 것과 같은 모양이며, 이쪽은 아직 해결되지 않았다.

실패 시나리오. 운영자가 한 destination에 compatibility: NONE_EXPERIMENTAL을 설정한다(설계 중이라는 이유로, 정당하게). 그 설정이 그대로 프로덕션으로 나간다. Kafka 보존 로그에 v1 스키마로 쓴 메시지가 남고, 이후 v2가 필드를 삭제하며 배포된다. 어떤 게이트도 그 삭제를 막지 않았고, 로그를 다시 읽는 consumer가 파싱에 실패한다. 이것이 javadoc이 "the log outlives every consumer"라고 적은 상황이며, 막는 코드는 존재하고 호출되지 않는다.

4.4 P2 — 호환성 게이트를 가진 두 포맷은 build-only이고, 출하되는 유일한 코덱에는 게이트가 없다
코덱 출하? 조립 지점 호환성 게이트
JacksonMessageCodec (JSON) 출하 MessagingCoreAutoConfiguration:366 messagingCodecs 없음
AvroMessageCodec build-only 없음 AvroCompatibilityGate (테스트에서만 실행)
ProtobufMessageCodec build-only 없음 ProtobufCompatibilityTest
RawBytesMessageCodec 출하(leaf) 없음 — 기본 코덱 금지 대상 n/a
DefaultCloudEventMapper 출하 없음 n/a

위험 방향이 뒤집혀 있다. 스키마 진화 검사가 존재하는 두 포맷(Avro·Protobuf)은 어떤 런타임에도 오르지 않고, 실제로 wire에 바이트를 쓰는 유일한 코덱(JSON)에는 포맷 수준의 호환성 게이트가 없다. §4.3의 포맷 독립 검증기(SchemaCompatibilityValidator)가 그 공백을 메울 자리인데 그것도 호출되지 않는다.

JSON의 진화 위험이 Avro보다 작은 것은 사실이지만 0은 아니다 — 필드 삭제, 타입 변경, enum 값 제거는 Jackson에서 런타임 역직렬화 실패로 나타난다. 그리고 SchemaPolicy가 destination마다 compatibility 모드를 선언하게 되어 있으므로(SchemaPolicy(codec, compatibility, messageTypes)), 선언은 있고 집행이 없는 상태다.

build-only 두 leaf의 미조립 자체는 등급과 일치하므로 결함이 아니다. 결함은 출하되는 쪽에 대응하는 게이트가 없다는 비대칭이다.

4.5 P2 — messaging-cloudevents는 출하 leaf이고 starter의 의존이며 소비자가 없다
modules.json:  messaging-cloudevents  runtime_memberships = ["app-bootstrap"]
build.gradle:  implementation project(':messaging:messaging-cloudevents')   <- starter가 문다

그런데 main 코드에서 DefaultCloudEventMapper를 만드는 곳은 0곳이고, CloudEventMapper·CloudEventExtensions를 참조하는 main 파일은 DefaultCloudEventMapper 자신뿐이다. 자동설정 28개 클래스 어디에도 CloudEvents 이름이 없다.

이 leaf는 3개 main 파일 전부가 출하 아티팩트에 들어가고 아무 경로에서도 호출되지 않는다. §1.1의 build-only 면제가 적용되지 않는다 — runtime_memberships["app-bootstrap"]이므로 이것은 출하되는 죽은 표면이다.

CloudEvents는 wire 상호운용 규격이므로, 이 상태의 구체적 위험은 "안 쓰이는 코드가 있다"가 아니라 CloudEvents 헤더로 메시지를 받을 것으로 기대하는 외부 소비자와의 계약이 아무 데서도 성립하지 않는다는 것이다. CanonicalEnvelopeHeaders(core-api)와 CloudEventExtensions 사이의 매핑이 존재하지만 어떤 publish/consume 경로도 그 매핑을 통과하지 않는다.

증거: 252-messaging-schema-and-observability-reachability.txt, 254-messaging-autoconfiguration-imports.txt


5. sub-scope 03 — policy · security · observability (66 파일)

leaf 출하? main test
messaging-policy 출하 26 4
messaging-security 출하 12 3
messaging-observability 출하 9 6
5.1 P2 — 출하되는 publish 경로는 관측을 하나도 기록하지 않는다

DefaultMessagePublisherMessagingObservation생성자 인자로 받는다. 그 필드의 javadoc이 이유를 적는다:

"MessagingObservation existed as a bean and no publish path called it, so the platform's own metrics described nothing. It is a constructor argument rather than an optional decorator because an unobserved publish path is how 'the dashboards were empty during the incident' happens."

수정은 runtime-core에서 정확히 이뤄졌다. 그런데 조립이 그 수정을 되돌린다. 자동설정이 고르는 것은 6인자 생성자다:

// MessagingCoreAutoConfiguration:445-447
return new dev.caskeleton.messaging.runtime.DefaultMessagePublisher(
    destinations, access, codecs, admission, runtimes, transport);

그 6인자 생성자는 8인자 생성자에 NO_OBSERVATION을 넘긴다 — 다섯 메서드가 전부 빈 본문인 익명 구현이다.

그리고 관측 leaf 전체에 프로덕션 소비자가 없다. messaging-observability(9 main, 출하)의 도달성:

타입 종류 main 참조 파일 조립
MessagingMetrics final class implements MessagingObservation (Micrometer) 0 없음
MessagingTracer final class 0 없음
MessagingAuditSink interface 0 main 구현 없음
DefaultMessagingObservationConvention final class 0 없음
MessagingRedactor final class 5 @Bean 있음 (:253)
CardinalityGuard final class 3 @Bean 있음 (:264)

MessagingCoreAutoConfiguration에서 관측 관련으로 등장하는 이름은 MessagingRedactorCardinalityGuard 둘뿐이다(grep 결과 4행). 즉 방출자는 하나도 만들지 않고 방출자의 협력자 두 개만 bean으로 공개한다. 그 두 bean은 아무도 주입받지 않는다.

MessagingMetrics는 버려진 코드가 아니다 — 카디널리티 설계가 구체적이다:

"Every tag set passes the CardinalityGuard before a meter is created. That ordering is the whole point: a meter registry never forgets a series, so a single tag carrying a message id permanently inflates the backend. ... Logical messages and physical attempts are separate meters. One message redelivered four times is one publish and five attempts; a single counter would make a redelivery storm read as traffic growth and hide the incident."

실패 시나리오. app.messaging.enabled=true로 이 플랫폼을 켠 배포에서 재배달 폭풍이 발생한다. messaging.publish 계열 메트릭이 하나도 만들어지지 않았으므로 대시보드는 비어 있고, MessagingAuditSink 구현이 없으므로 감사 로그도 없으며, MessagingTracer가 조립되지 않았으므로 트레이스 링크도 없다. 남는 것은 애플리케이션 로그뿐이고, DefaultMessagePublisher가 예외 메시지를 의도적으로 버리므로(§3.2) 거기에는 예외 타입 이름만 있다.

정보를 버리는 그 결정(§3.2)은 관측 경로가 살아 있을 때 옳다. 관측 경로가 no-op일 때 그 둘이 합쳐지면 진단 가능한 흔적이 남지 않는다. 이것이 이 발견을 P3이 아니라 P2로 두는 이유다.

증거: 253-messaging-observation-assembly.txt

5.2 P2 — 브로커 ACL 매니페스트의 자기 점검이 존재하지 않는다

BrokerAclManifest(messaging-security, 출하) — main 참조 0건, 테스트 1건.

record의 javadoc이 자기 용도를 명시한다:

"Written down so the grant can be reviewed and diffed rather than discovered from a broker dump. The manifest is what the platform checks itself against at startup: a runtime that holds more than it declares is a finding, because the extra permission is the one nobody reasoned about. Destructive permissions are named separately from ordinary ones. DELETE_TOPIC and PURGE are not 'write, but more'; they destroy data an application can never restore, so an application runtime declaring one is rejected outright."

두 문장 모두 실행되는 코드가 아니다:

  • "checks itself against at startup" — 그 startup 검사가 없다. StartupProfileValidation으로 감싼 것은 Kafka·Rabbit 브로커 프로파일 두 개뿐이다(§3.5).
  • "an application runtime declaring one is rejected outright" — 그 거부도 없다. DELETE_TOPIC/PURGE를 선언한 매니페스트를 만드는 것을 막는 코드가 record 자신에도 없다(compact constructor는 principal/pattern 공백만 검사).

브로커 권한이 선언보다 넓은지 확인하려면 브로커에 질의해야 하므로 "runtime이 선언보다 많이 들고 있는지"의 완전한 검사는 어댑터 협조가 필요하다. 그러나 매니페스트 자체의 파괴적 권한 거부는 순수 로컬 검사이고, javadoc이 "rejected outright"라고 단정한 것이며, 지금 아무 곳에서도 일어나지 않는다.

5.3 P3 — 접근 검사가 두 갈래로 존재하고, 조립된 쪽이 진단이 약한 쪽이다 (§8.3)

같은 권한 검사가 두 형태로 있다.

(a) 조립된 쪽DefaultMessagePublisherDestinationAccessPolicy를 직접 호출:

if (!access.mayPublish(destination.name())) {
  return rejected("PUBLISH_FORBIDDEN",
      "this application may not publish to '" + destination.name().value() + '\'', startedAt);
}

FailureCategory.CONFIGURATION · retryable=falsePublishResult를 돌려준다.

(b) 조립되지 않은 쪽DestinationAccessValidator (main 참조 0건, 테스트 0건):

public void requirePublish(DestinationName destination) {
  if (!policy.mayPublish(destination)) {
    throw new MessageAuthorizationException("DESTINATION_PUBLISH_DENIED",
        "the producer credential may not publish to " + destination.value());
  }
}

클래스 javadoc: "Runs before the broker's ACL, and the failure it raises names the logical destination and the role. A broker ACL denial arrives as a connection-level error with no application context, which makes 'which module tried to publish where' an investigation rather than a log line."

두 경로의 차이는 분류다. (a)는 인가 거부를 CONFIGURATION으로 분류하고, (b)는 MessageAuthorizationException을 던진다. messaging-core-api의 26개 예외에는 MessageAuthorizationException이 명시적으로 있고 FailureCategory에도 대응 항목이 있을 것으로 기대되는데, 실제 publish 경로는 그 타입을 쓰지 않는다.

권한 거부가 CONFIGURATION으로 집계되면 "설정 실수"와 "권한 침해 시도"가 같은 버킷에 들어간다. 실제 검사 자체는 (a)에서 수행되므로 보안 구멍은 아니다 — 분류와 진단의 문제이고, 중복 장치 중 조립되지 않은 쪽이 더 정확한 분류를 갖고 있다.

5.4 P3 — 자격 증명 회전 개념이 두 번 표현되고, 하나만 살아 있다 (§8.3)
  • 살아 있는 쪽: CredentialRuntimeRegistry + CredentialRuntime.isDueForRotation(now). 레지스트리가 resolve 시점에 회전을 수행하고, dueForRotation(now)로 회전 창에 든 자격 증명 id를 열거한다. 동시성 계약이 테스트로 고정돼 있다 — CredentialRotationContractTest가 "100개 동시 resolve가 한 번만 fetch하고 한 세대만 공개"를 검증하고, 이전 결함(get→fetch→put→clear 무동기화로 교체본 하나가 clear 없이 버려져 아무도 소유하지 않는 비밀이 메모리에 남던 상태)을 서술한다.
  • 죽은 쪽: CredentialRotationPlan record — main 참조 0, 테스트 참조 0. isDue(Instant)를 갖고 있고 "Rotation is scheduled ahead of expiry rather than triggered by an authentication failure"라는 같은 원칙을 담는다.

같은 개념의 두 표현 중 하나가 완전히 죽어 있다. 살아 있는 쪽이 더 나은 쪽(동시성 계약 + 테스트)이므로 위험은 낮다. 기록으로 남기고, 제거 대상 후보로 표시한다.

5.5 검증된 설계 — 재시도 결정이 capability를 읽는 두 지점

DefaultRetryDecisionEngine이 §3.4에서 센 12개 플래그 중 2개를 읽는 유일한 main 코드다:

// :49  순서 보존이 필요한데 destination이 ordered stream이면
&& context.capabilities().orderedStream()
// :64  BROKER_DELAYED 모드인데 브로커가 지연 전달을 지원하면
if (policy.mode() == RetryMode.BROKER_DELAYED && context.capabilities().delayedDelivery())

둘 다 분기이지 거부가 아니다. 재시도 엔진에서는 이것이 합리적이다 — 브로커가 지연 전달을 못 하면 인프로세스 백오프로 떨어지는 것이 정상 동작이다. §3.4가 문제 삼는 것은 이 두 지점이 아니라, 거부해야 하는 나머지 플래그에 거부가 없다는 점이다.

5.6 P3/기록 — messaging-security의 비밀 유출 검사는 관측 leaf에 있고, 정적 스캐너로 이중화돼 있다

messaging-observability의 테스트 6개 중 3개가 유출 방지 계약이다 — MessagingSecretLeakTest, SecretLeakStaticScanTest, SecretLeakScannerCharacterizationTest. 마지막 것이 특히 드물다: 스캐너 자신의 동작을 특성화 테스트로 고정한다(스캐너가 무엇을 잡고 무엇을 놓치는지를 테스트로 적어 둔다).

MessagingRedactor(main 참조 5, bean 등록됨)가 런타임 편집을 담당하고, 정적 스캔이 소스 수준을 담당한다. 두 층이 서로를 대체하지 않고 보완한다.

다만 §5.1과 겹쳐 읽으면 한계가 보인다 — MessagingRedactorMessagingMetrics·MessagingAuditSink·MessagingAuditEvent·MessagingObservation에서 참조되는데, 그 넷 중 조립되는 것이 없다. 즉 런타임 편집기는 bean으로 존재하지만 편집할 대상 경로가 조립돼 있지 않다. 정적 스캔은 조립과 무관하게 계속 유효하다.


6. sub-scope 04 — brokers (134 파일)

leaf 출하? main test main LOC 등급(CompatibilityMatrix)
messaging-kafka 출하 34 24 3,427 STABLE
messaging-rabbit 출하 20 10 2,443 EXPERIMENTAL
messaging-pulsar-experimental build-only 8 2 663 EXPERIMENTAL
messaging-nats-experimental build-only 7 2 755 EXPERIMENTAL
messaging-kafka-share-experimental build-only 4 1 190 EXPERIMENTAL
messaging-spring-cloud-stream-bridge build-only 6 2 507 표에 없음
6.1 검증된 설계 — 전송 선택이 classpath 사고가 아니라 속성이다

MessagingProviderSelection의 javadoc이 이전 상태를 적는다:

"Selection used to be @ConditionalOnClass: Kafka assembled because a Kafka class was on the classpath and Rabbit because a Rabbit class was, so an application with both libraries — one transitive dependency is enough — assembled both and published through whichever bean won a @ConditionalOnMissingBean race. Nothing failed; the message simply went somewhere nobody chose."

그리고 starter가 messaging-kafkamessaging-rabbit둘 다 물면서 각각 클라이언트 라이브러리를 api로 노출하므로 그 조건은 모든 채택자에게 참이었다 — 즉 @ConditionalOnClass는 선택할 수 없는 조건이었다.

지금은 세 가지가 침묵이 아니라 startup 오류다: (1) 레지스트리 밖 broker id, (2) 등록됐지만 클라이언트 라이브러리가 없는 broker, (3) messaging이 켜졌는데 broker가 비어 있는 경우.

그리고 MessagingPlatformRootAutoConfiguration이 master switch를 단독 소유한다:

@AutoConfiguration
@ConditionalOnProperty(prefix = MessagingSettings.PREFIX, name = "enabled", havingValue = "true")
@Import({MessagingCoreAutoConfiguration.class, MessagingProviderSelection.class,
         MessagingReliabilityAutoConfiguration.class, MessagingAdminAutoConfiguration.class})

matchIfMissing이 없다 — 기본 꺼짐, fail-closed. AutoConfiguration.imports에 등록된 클래스도 이 하나뿐이다. 자식 설정은 조건을 갖지 않는다:

"one root owning the condition, importing children that carry none, so a bean added to any child next month is gated without anyone remembering to repeat a condition."

이것이 모듈 14 §8.1이 web 어댑터에서 없다고 판정한 바로 그 구조다 — messaging은 갖고 있다.

6.2 P2 — messaging-rabbit은 출하되지만 선택할 수 없고, 운영 문서는 그것을 말하지 않는다

MessagingProviderSelectionBROKERS_WITHOUT_A_TRANSPORT 맵이 있다:

static final Map<String, String> BROKERS_WITHOUT_A_TRANSPORT =
    Map.of("rabbit",
        "the Rabbit adapter ships its validators and security configuration but no "
            + "MessagingTransport: its native channel publisher is not implemented, so a publish "
            + "has nothing to travel on");

확인:

  • RabbitChannelPublisher는 interface이고 구현이 어디에도 없다(main·test 통틀어 implements RabbitChannelPublisher 0건).
  • RabbitMessagingAutoConfiguration에는 MessagingTransport bean이 없다. KafkaMessagingAutoConfiguration에는 있다(:164 messagingKafkaTransport).

코드 쪽은 훌륭하다. 선택 오류를 다른 선택 오류들 옆에서 이름으로 거부하고, javadoc이 "Registered is not the same as assemblable"라고 구분을 명시하며, "An entry leaves this map on the day its transport does exist"로 해제 조건까지 적는다. 이것은 결함이 아니라 fail-closed 설계다.

결함은 운영 문서다. docs/messaging/support-matrix.md의 브로커 등급 표:

브로커 등급 인증 기준 Stable 기능 제한
RabbitMQ Experimental 4.3.x exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ 장애 시나리오 레인 미실행 — 증거 없음. stream 및 특수 plugin 미지원

제한 칸이 말하는 것은 "증거가 없다"뿐이다. "전송이 구현되지 않아 선택 자체가 startup 오류"라는 사실이 어디에도 없다. 기능 칸은 publisher confirm·mandatory return·quorum queue를 나열하고 있어, 읽는 사람은 "검증은 덜 됐지만 쓸 수는 있는 옵션"으로 이해한다.

실패 시나리오. 팀이 지원 매트릭스를 읽고 RabbitMQ를 고른다(Experimental이지만 기능 목록이 요구사항을 덮으므로 합리적 선택이다). app.messaging.enabled=true, app.messaging.broker=rabbit으로 배포한다. 애플리케이션이 부팅에 실패한다. 실패 메시지 자체는 정확하지만, 그 정보가 선택을 하기 전에 도달했어야 한다. 그리고 이 상태는 임시가 아니다 — 2,443 LOC짜리 어댑터가 20개 main 파일로 출하 아티팩트에 들어 있고, 그중 전송 경로만 없다.

즉 이 어댑터에서 "장치는 있고 회로가 닫히지 않았다"가 코드에서는 정직하게 공개돼 있고 문서에서만 공개되지 않았다.

6.3 P1 — 지원 매트릭스가 Kafka의 deduplicatedPublishO로 적고, 코드는 false이며, 그 차이가 정확히 코드가 경고한 피해다

docs/messaging/support-matrix.md의 capability 표(5개 어댑터 × 12개 플래그 = 60칸)를 코드의 MessagingCapabilities 배열과 전수 대조했다. 일치하지 않는 칸은 하나다.

record 성분 순서: brokerAcknowledgement, replicationOrPersistenceEvidence, perMessageSettlement,
                  batchSettlement, orderedStream, keyedOrdering, replay, delayedDelivery,
                  brokerTransaction, deduplicatedPublish, nativeDeadLetter, topologyManagement

KafkaMessagingTransport.CAPABILITIES =
    (true, true, true, true, true, true, true, false, true, FALSE, false, true)
                                                              ^^^^^ deduplicatedPublish

문서:

| deduplicatedPublish | O | X | X | X | O |
                        ^ Kafka

Rabbit·Kafka Share·Pulsar·NATS의 48칸은 전부 코드와 일치한다(NATS의 deduplicatedPublish=trueO, Rabbit의 nativeDeadLetter=trueO, Pulsar의 keyedOrderingKey_Shared만으로 두 배열 차이까지 반영 — 문서가 코드보다 더 정밀한 칸도 있다). 틀린 칸은 Kafka의 dedup 하나다.

그리고 그 칸이 하필 §3.4에서 확인한 12개 중 유일하게 실제로 거부를 발생시키는 플래그다. 코드의 javadoc은 그 값이 true였다가 바뀐 이력과 이유를 길게 적는다:

"It was true, justified by producer idempotence. Producer idempotence deduplicates sequence retries within one producer session: the producer id is reassigned on restart, so the same logical message published again after a crash is a new sequence and the broker stores it twice. The capability an application reads is 'the broker suppresses duplicate publishes of a stable id', which needs a persistent message-id store that this platform does not have.

Declaring it true means PublishDeduplication is accepted and silently does nothing — the caller believes the broker is deduplicating and skips the idempotency it would otherwise build. False makes that request a startup failure instead, which is the whole reason the flag exists."

문서가 지금 그 true를 말하고 있다.

실패 시나리오 (코드 javadoc이 서술한 것 그대로, 문서를 통해 발생):

  1. 팀이 지원 매트릭스에서 Kafka deduplicatedPublish = O를 읽는다.
  2. "브로커가 안정된 id의 중복 publish를 억제한다"는 문서상 보장을 근거로, 소비자 측 멱등성 구현을 생략한다 — 이것이 그 플래그를 읽는 유일한 이유다.
  3. 프로듀서가 크래시 후 재시작하며 같은 논리 메시지를 다시 publish한다. producer id가 재할당됐으므로 브로커는 새 시퀀스로 보고 두 번 저장한다.
  4. 중복이 소비자에 도달하고, 생략된 멱등성이 있어야 할 자리에 아무것도 없다.

코드는 옳고, 코드가 옳게 하려고 남긴 문서가 틀렸다. 이 항목을 P1로 두는 이유는 세 가지다 — (a) 지금 배포 가능한 유일한 브로커(§6.2)에 대한 것이고, (b) 데이터 정합성 보장에 관한 것이며, (c) 코드 자신이 "이 믿음이 피해"라고 이름 붙인 바로 그 믿음을 문서가 만들어 낸다.

부수 관찰(결함 아님). Rabbit의 delayedDelivery는 코드가 true, 문서가 retry queue로 대행이다. AMQP에 네이티브 지연 전달이 없고 RabbitRetryQueueTopology(TTL+DLX)가 그것을 대행하므로, 문서 쪽이 코드 플래그보다 더 정확하다. MessagingCapabilities가 "브로커 네이티브"와 "어댑터 제공"을 구분하지 않는다는 점을 기록으로 남긴다.

증거: 255-messaging-capability-doc-vs-code-drift.txt

6.4 P2 — 지원 매트릭스가 "모든 messaging leaf는 build-only"라고 적고, 가족 권위 문서는 그 문장이 틀렸다고 이미 기록했다

docs/messaging/support-matrix.md:

"또한 registry의 messaging leaf는 모두 runtime_memberships가 비어 있다. 이는 build-only / incubating — 어느 composition root에도 편입되지 않았다는 뜻이며, 아래의 등급과는 다른 축이다."

src/messaging/CLAUDE.md (같은 저장소, 이 가족의 로컬 권위):

"이 절은 한동안 사실이 아닌 채로 남아 있었다. 'registry의 모든 messaging leaf는 runtime_memberships가 비어 있고 따라서 build-only'라고 쓰여 있었는데, 다섯 어댑터 remediation이 messaging-spring-boot-starterapp-bootstrap 의존성으로 넣으면서 그 closure 전체가 런타임 classpath에 올라갔다. 정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다."

레지스트리 실측(§1.1): 출하 18 / build-only 7.

같은 저장소의 두 문서가 정반대를 말하고, 한쪽은 자기가 틀렸었다는 사실과 그 원인(산문에서 세는 것)까지 적어 두었으면서, 다른 쪽은 고쳐지지 않았다. 그리고 고쳐지지 않은 쪽이 운영자용 문서다.

이 드리프트의 실질적 무게는 이 문서 전체의 심각도 판정 축과 같다(§1.1). 지원 매트릭스만 읽은 운영자는 messaging이 아무것도 출하하지 않는다고 결론 내리는데, 실제로는 messaging-kafka·messaging-rabbit·messaging-spring-boot-starter·messaging-security·messaging-observability·messaging-cloudevents·outbox/inbox/claim-check·admin plane이 전부 app-bootstrap 아티팩트에 실려 있다.

6.5 P2 — 한 아티팩트 안의 서로 모르는 Kafka 스택 두 개 (MSG-015, 가족 문서가 미해결로 표시)

src/messaging/CLAUDE.md가 MSG-015를 P0 미해결로 들고 있다. 현재 상태를 코드로 확인했다.

app-bootstrap seam messaging platform
설정 클래스 bootstrap/messaging/KafkaSenderConfig autoconfigure/KafkaMessagingAutoConfiguration
producer bean kafkaSeamProducer : Producer<String,String> messagingKafkaProducer : Producer<byte[],byte[]>
조건 @ConditionalOnProperty(name="app.messaging.broker", havingValue="kafka") root의 app.messaging.enabled=trueMessagingProviderSelectionbroker=kafka
의존 방향 adapter-outbound-messaging은 platform에 의존하지 않음 platform은 adapter를 모름

두 스택이 같은 속성 이름 위에서 켜진다. 그리고 조건의 깊이가 다르다:

  • app.messaging.enabled=false(기본) + app.messaging.broker=kafkaseam 스택만 올라온다. 플랫폼의 master switch가 꺼져 있는데도 app.messaging.broker가 다른 Kafka producer 하나를 만든다.
  • enabled=true + broker=kafka둘 다 올라온다. 직렬화 방식이 다른(String vs byte[]) 두 producer가 같은 클러스터를 향해 각자 열린다.

bean 이름 충돌은 해소됐다 — KafkaSenderConfig의 javadoc이 그 과정을 적는다:

"Sharing the method name messagingKafkaProducer made the context refuse to start with a BeanDefinitionOverrideException, and a type-scoped @ConditionalOnMissingBean would have been worse: whichever configuration lost the race would leave its own stack without a producer while the other stack's, with incompatible serializers, sat in its place."

그 판단은 옳다. 그러나 결과적으로 이 결함의 유일한 가시적 증상이 제거됐다. 지금은 부팅이 성공하고, 두 스택이 조용히 공존한다. CLAUDE.md의 문장이 그대로 성립한다 — "지금 안전한 이유는 설계가 아니라 기본값이다app.messaging.enabled=false."

정정할 부분이 하나 있다. CLAUDE.md는 안전의 근거를 enabled=false에 둔다. 그런데 seam 스택은 enabled를 보지 않고 broker만 본다. 따라서 enabled=false는 두 스택 중 하나만 막는다. 기본값이 지켜 주는 범위가 문서가 말하는 것보다 좁다.

이 발견은 모듈 12(adapter-outbound-messaging)의 반대편이다. 그쪽에서는 "플랫폼에 도달하는 bridge가 없다"로 기록했고, 이쪽에서 확인한 것은 그 bridge의 부재가 두 개의 producer 조건 사이의 비대칭으로 나타난다는 점이다.

증거: 256-..., 257-messaging-msg015-two-kafka-stacks.txt

6.6 검증된 설계 — 등급이 boolean이 아니라 증거에서 파생된다

CompatibilityMatrix.Entry.hasLiveBrokerCertification():

public boolean hasLiveBrokerCertification() {
  return BrokerFailureMatrix.from(CertifiedEvidence.recorded()).hasLiveBrokerCoverage(adapter);
}

javadoc: "Read from the evidence rather than declared. As a field it was a boolean an author set next to the tier, and RabbitMQ carried true while no fault scenario had ever been executed against it — the same shape as the coverage matrix that used to assert its own constants."

CompatibilityMatrixTest가 강제하는 규칙 9개:

everyStableAdapterRunsTheSharedContract
noExperimentalAdapterIsEnabledByDefault
theStableSetIsExactlyWhatALaneHasCertified
aCertificationClaimCannotBeMadeWithoutEvidence
kafkaIsCertifiedAgainstWhatTheLanesRun
theSharedContractStillDeclaresEveryRequiredTest
anUnknownAdapterIsNotSilentlyTreatedAsSupported
everyStableAdapterIsCertifiedAgainstALiveBroker
noExperimentalAdapterClaimsLiveBrokerCertification
anAdapterMustCertifyAtLeastOneBrokerVersion

그리고 버전 목록에도 같은 규율이 적용됐다 — Kafka 항목의 주석: "4.1 only. The declared 4.2/4.3 were never run: the fixtures pin apache/kafka:4.1.0 and the locked client is 4.1.1, so the matrix and the support document agreed with each other and with nothing that had executed."

이 문장이 §6.3·§6.4가 남아 있는 이유를 설명한다. 문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다는 원칙이 여기서는 적용됐고, capability 표와 membership 문장에는 아직 적용되지 않았다. capability는 MessagingCapabilities에 손으로 쓴 12개 boolean이고(§3.4), 그것을 검증하는 테스트가 없다.

6.7 P3 — CompatibilityMatrixEXTENSION 등급이 있고 항목이 없으며, bridge leaf가 표 밖에 있다

Tier enum은 세 값을 갖는다 — STABLE, EXPERIMENTAL, EXTENSION("Adapter SPI only; outside the supported set"). ENTRIES 5개는 전부 STABLE 또는 EXPERIMENTAL이고 EXTENSION을 쓰는 항목은 없다.

한편 messaging-spring-cloud-stream-bridge(6 main, 507 LOC, build-only)는 ENTRIES에 없다. CompatibilityMatrix.of("...")는 미등록 이름에 예외를 던지므로 조용히 지원되는 것으로 취급되지는 않는다 — 그 점은 anUnknownAdapterIsNotSilentlyTreatedAsSupported가 강제한다.

그런데 docs/messaging/support-matrix.md의 브로커 등급 표에는 Artemis/JMS | Extension | 범위 밖 | adapter SPI만 | 별도 ADR + Contract Suite 통과 필요 행이 있다. 즉 문서에는 EXTENSION 등급 행이 있고 코드 ENTRIES에는 없으며, 저장소에 실재하는 bridge leaf는 양쪽 어디에도 없다.

EXTENSION이 정확히 bridge를 서술한다 — MessagingBindingBridge의 javadoc이 "The bridge is an interoperability seam, not a second messaging API"라고 하고, StreamBridgePolicyGuard가 "ordering scope, retry policy, dead letter destination을 선언한 destination은 native adapter로 가야 한다"로 정책 경계를 강제한다. build-only 등급이므로 오늘의 사고는 아니다. 표의 빈 등급과 표 밖의 leaf를 기록으로 남긴다.

6.8 검증된 설계 — 예약 헤더 위조 방어가 두 출하 어댑터에서 대칭이다

ReservedHeaders(msg.id, msg.type, msg.tenant 등 15+개)에 대해, 애플리케이션이 canonical 값을 덮어쓰지 못하게 하는 가드가 Kafka와 Rabbit 양쪽 매퍼에 동일하게 있다:

envelope.headers().asMap().forEach((name, value) -> {
  if (CanonicalEnvelopeHeaders.restatesEnvelopeField(name.value())) {
    throw new MessagingConfigurationException("RESERVED_HEADER_FORGED", ...);
  }
  put(headers, name.value(), value.value());
});

두 매퍼의 주석도 같은 근거를 든다 — 봉투의 헤더 맵이 마지막에 쓰이고 소비자가 lastHeader를 읽으므로, 검사 없는 이름이 방금 쓴 canonical 값을 덮는다. "A forged msg.id corrupts another message's inbox deduplication, not merely its own delivery."

그리고 가드 범위가 정확히 좁다 — "Only names that restate an envelope field are refused. Retry attempt, failure and redrive bookkeeping have no envelope field to be read back from, so refusing them would make every retry-topic and dead-letter re-publish fail on the guard meant to protect identity."

테스트도 양쪽에 있다: KafkaEnvelopeRoundTripTest.aForgedMessageIdIsStillRefused + 전용 ReservedHeaderForgeryTest, RabbitEnvelopeRoundTripTest.aForgedMessageIdIsStillRefused. 비대칭 없음.

6.9 P3/기록 — experimental 어댑터 3종의 "AdapterContractTest"는 공유 계약을 돌리지 않는다

MessagingAdapterContract(testkit main)를 실제로 참조하는 어댑터 테스트는 두 개뿐이다:

messaging-kafka/src/test/.../KafkaProducerContractTest.java
messaging-rabbit/src/test/.../RabbitAdapterContractTest.java

PulsarAdapterContractTest·NatsAdapterContractTest는 이름에 "AdapterContract"가 들어가지만 그 타입을 참조하지 않는다. CompatibilityMatrix.ENTRIES가 이 사실과 일치한다 — pulsar·nats·kafka-share는 runsSharedContract = false이고, everyStableAdapterRunsTheSharedContract가 STABLE에만 그것을 요구한다. 선언과 코드가 일치하므로 결함이 아니다. 다만 테스트 이름이 계약 실행을 시사하므로 기록한다.

CrossBrokerContractSuitemessaging-testkit/src/test에 있다 — 테스트 소스셋이므로 다른 leaf가 의존할 수 없다. 릴리스 게이트 역할은 §2.3의 verifyMessagingCertificationEvidence(Gradle)가 수행한다.


7. sub-scope 05 — reliability stores (52 파일)

leaf 출하? main test main LOC 마이그레이션
messaging-outbox-jdbc-postgresql 출하 13 8 2,276 V1·V2·V3·V4
messaging-inbox-jdbc-postgresql 출하 6 4 542 V2
messaging-claim-check 출하 6 3 418

세 leaf 모두 runtime_memberships = ["app-bootstrap"]이다.

7.1 P2 — outbox/inbox 체인 전체가 만족되지 않는 @ConditionalOnBean 뒤에 있다

MessagingReliabilityAutoConfiguration의 조건 사슬:

@ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class})
public OutboxRelay outboxRelay(OutboxRepository outbox, ..., OutboxEnvelopeFactory envelopes, ...)

@ConditionalOnBean(OutboxRelay.class)
public OutboxRelayWorker outboxRelayWorker(...)

@ConditionalOnBean(OutboxRelayWorker.class)
public MessagingOutboxRelayLifecycle outboxRelayLifecycle(...)

@ConditionalOnBean(OutboxRepository.class)
public OutboxCleanupJob outboxCleanupJob(...)

@ConditionalOnBean(InboxRepository.class)
public InboxCleanupJob inboxCleanupJob(...)

@ConditionalOnBean(IdempotentConsumer.class)
public TransactionalInboxHandler<Object> transactionalInboxHandler(...)

사슬의 뿌리는 OutboxRepositoryInboxRepository bean이다. 그 두 port의 구현은 각각 JdbcOutboxRepositoryJdbcInboxRepository 하나씩뿐이고, 둘 다 main 코드에서 참조가 0건이다 — 어떤 자동설정도, 어떤 다른 main 클래스도 생성하지 않는다. @Bean도 없다.

따라서 사슬 전체가 조립되지 않는다. app.messaging.enabled=true로 플랫폼을 켜도 outbox relay는 없고, relay worker도, lifecycle도, cleanup job도, inbox cleanup도, transactional inbox handler도 없다. 13 + 6 = 19개 main 파일, 2,818 LOC가 전부 조용히 비어 있다.

사이클 2 보강 — 원인은 조건이 아니라 중복이다(EVD-335). 위 서술은 현상으로는 맞지만 원인을 가린다. 스타터의 클래스 javadoc 은 이 조건들을 결함이 아니라 계약으로 서술한다 — "The platform cannot provide those: they write inside the application's own transaction, against the application's own datasource." 그렇다면 남는 질문은 출하 애플리케이션이 그 계약을 이행하는가인데, 답은 이행하지 않고 자기 outbox 를 갖고 있다다.

스택 A (출하) 스택 B (어둡다)
포트 application-core/.../outbox/OutboxStorePort 외 15파일 messaging-reliability-api/.../OutboxRepository
구현 persistence-jpa/.../outbox/OutboxStoreAdapter@Repository JdbcOutboxRepository — 스테레오타입 없음
구동 app-bootstrap/.../outbox/OutboxConfig + OutboxRelayScheduler@Scheduled 없음

수정 방향이 반대가 된다. "조건이 만족되지 않는다"로 읽으면 app-bootstrap 에 빈을 등록하는 수정이 되고, "outbox 가 둘이다"로 읽으면 어느 쪽이 정본인지 먼저 결정해야 하는 문제가 된다. 두 스택은 저장 모델도 발행 경로도 다르므로, 둘을 동시에 켜면 같은 업무 이벤트가 두 테이블에 적히거나 두 번 발행될 수 있다.

이것이 §5.1(관측)과 다른 점은 실패가 더 조용하다는 것이다. 관측은 no-op 구현이 명시적으로 존재하지만, 여기서는 @ConditionalOnBean이 만족되지 않아 bean이 아예 생기지 않는다. Spring은 그것을 오류로 보고하지 않는다 — 조건부 bean이 조건을 만족하지 못하는 것은 정상 동작이다. 즉 "outbox가 꺼져 있음"과 "outbox가 조립될 수 없음"이 런타임에서 구별되지 않는다.

@ConditionalOnBean(OutboxRepository.class)가 애플리케이션이 직접 repository bean을 등록하는 것을 전제하는 확장점이라면 그것은 정당한 설계다. 그러나 그렇다면 저장소 안에 구현이 존재하는데 그것을 등록하는 자동설정이 없다는 점이 설명되지 않는다 — JdbcOutboxRepository는 이 저장소가 만든 PostgreSQL 전용 구현이고, messaging-spring-boot-starter가 그 leaf를 implementation으로 물고 있다. MessagingCoreAutoConfigurationMessageCodecRegistry에 대해 한 것(§4.2 — 기본 구현을 @ConditionalOnMissingBean으로 제공)과 대비된다.

실패 시나리오. 팀이 outbox 패턴을 쓰려고 app.messaging.enabled=true를 켜고 docs/messaging/outbox-inbox.md를 따라 destination에 outbox를 설정한다. 부팅은 성공한다. 애플리케이션 트랜잭션이 outbox 행을 쓰려 해도 OutboxRepository bean이 없으므로 주입 지점에서 컨텍스트가 실패하거나(애플리케이션이 주입을 시도한 경우), 아무 일도 일어나지 않는다(플랫폼 경로만 쓴 경우). 후자에서는 relay가 없으므로 행이 쌓이지도 않고 발행되지도 않는다.

7.2 P2 — messaging 마이그레이션 스트림을 적용하는 곳이 없고, 적용하려는 순간 버전이 충돌한다

(a) 적용되지 않는다. 합성 루트의 Flyway 기본 위치는 PostgreSqlPersistenceConfig:115가 고정한다:

configuration.locations("classpath:db/migration/postgresql");

조건부 스트림은 각자 자기 위치와 history table을 갖는다 — NotificationSchemaStream.LOCATION = "classpath:db/migration/jpa/notification-platform", fileserver 스트림 등. db/migration/messaging을 이름으로 부르는 main 코드는 저장소 전체에 0건이다. 참조는 세 개의 IT(InboxPostgresIT, OutboxPostgresIT, AdminOperationJournalPostgresIT)가 자기 테스트 컨테이너에 직접 적용할 때뿐이다.

messaging_outbox · messaging_inbox · admin operation journal 테이블은 출하 배포 어디에서도 생성되지 않는다. §7.1과 합치면 일관은 있다 — repository bean이 없으니 테이블도 필요 없다. 그러나 persistence-jpa leaf가 같은 모양의 결함을 세 번 고치고 그 이력을 javadoc에 남겨 두었다:

"PostgreSqlSameStoreInboxAdapter ... its tables live only in db/migration/jpa/inbox. The bean existed, its tables did not, and the failure arrived either at ..." (같은 문장이 PostgreSqlImmutableOutboxAppendAdapter, PostgreSqlPollingDeliveryAdapter에도 있다)

messaging 쪽은 그 반대 순서다 — 테이블 정의는 있고 bean이 없다. 어느 쪽이든 스키마와 코드가 같은 조립 결정 아래 있지 않다는 같은 문제다.

(b) 적용하려 하면 충돌한다. 두 leaf가 같은 classpath 리소스 디렉터리 db/migration/messaging/에 각자 번호를 매긴다:

messaging-inbox-jdbc-postgresql   : V2__messaging_inbox.sql                (CREATE TABLE messaging_inbox)
messaging-outbox-jdbc-postgresql  : V1__messaging_outbox.sql
                                    V2__messaging_outbox_lease_fencing.sql (ALTER TABLE messaging_outbox ×4, CREATE INDEX)
                                    V3__messaging_admin_operation_journal.sql
                                    V4__messaging_outbox_canonical_metadata.sql

V2가 두 개다. 두 jar가 한 classpath에 있고 Flyway가 classpath:db/migration/messaging을 스캔하면 "Found more than one migration with version 2"로 실패한다. 지금 실패하지 않는 유일한 이유는 (a) — 아무도 그 위치를 Flyway에 주지 않기 때문이다.

각 leaf의 IT는 자기 jar의 리소스만 보므로 이 충돌을 재현하지 못한다 — InboxPostgresIT:199V2__messaging_inbox.sql을 파일명으로 직접 읽고, OutboxPostgresIT:249는 자기 디렉터리를 나열한다. 두 leaf를 한 classpath에 올린 상태를 검증하는 테스트가 없다.

원래 구현 계획서는 분리된 위치를 지정했다:

docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:3010
  modules/messaging/messaging-outbox-jpa/.../db/migration/messaging-outbox/V1__create_messaging_outbox.sql
docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:3011
  modules/messaging/messaging-inbox-jpa/.../db/migration/messaging-inbox/V1__create_messaging_inbox.sql
:3032  migrate("classpath:db/migration/messaging-outbox");
:3055  migrate("classpath:db/migration/messaging-inbox");

구현이 두 스트림을 하나로 합치면서 번호 조정은 하지 않았다.

실패 시나리오. outbox를 실제로 쓰려는 팀이 §7.1을 해결한다(repository bean 등록). 다음 단계는 테이블 생성이고, 유일한 방법은 db/migration/messaging을 Flyway 위치에 추가하는 것이다. 그 순간 부팅이 duplicate version으로 실패한다. 고치려면 마이그레이션 번호를 바꿔야 하는데, 다른 환경에 이미 적용된 뒤라면 checksum/version 이력을 손대는 작업이 된다 — 가장 늦게, 가장 비싸게 발견되는 자리다.

증거: 259-messaging-flyway-migration-namespace.txt

7.3 검증된 설계 — outbox lease가 소유자와 fencing token을 갖는다

V2__messaging_outbox_lease_fencing.sql의 헤더가 고친 결함을 서술한다:

"V1 recorded only lease_expires_at, so a claim said when it would end and nothing about who held it. The relay's terminal writes matched on message_id alone, which means a worker that stalled past its lease could still write: relay A claims the row and calls the broker the lease expires; relay B reclaims it, publishes, and records PUBLISHED relay A finally times out and records AMBIGUOUS over the top The row is now claimable again and the message is published a second time. Making the lease longer than the publish timeout lowers the odds; it does not turn a GC pause, a scheduler stall ..."

lease 만료를 시간으로만 다루는 것과 소유권 토큰으로 다루는 것의 차이를 정확히 짚는다. 그리고 실제 인프라 테스트가 이 계열의 결함 두 건을 잡았다는 기록이 docs/messaging/support-matrix.md에 남아 있다 — "Outbox IN_FLIGHT 고아 행: lease 쿼리가 PENDING/AMBIGUOUS만 클레임 대상으로 봐서, publish 도중 죽은 relay가 남긴 행이 lease 만료 후에도 영영 회수되지 않았다."

inbox 쪽 설계도 같은 수준이다 — V2__messaging_inbox.sql:

"The composite primary key is the deduplication mechanism: reserving a message is an INSERT that either succeeds or violates the key, inside the same transaction as the handler's side effect. Two independent consumers of the same event each get their own row, so one cannot suppress the other."

(message_id, consumer_id) 복합 키로 소비자별 dedup을 보장한다. 이 leaf들은 코드 품질 문제가 아니라 조립 문제다.

7.4 P3 — claim-check는 starter에 배선 코드가 한 줄도 없다

messaging-claim-check(6 main, 418 LOC, 출하)의 ClaimCheckPublisher·ClaimCheckResolver는 main 참조 0건이고, MessagingReliabilityAutoConfigurationClaimCheck 문자열이 등장하지 않는다.

ClaimCheckIntegrityGuard·ClaimCheckPolicy·ClaimCheckStore는 leaf 내부에서 서로를 참조하므로 leaf는 내부적으로 일관되지만, 바깥에서 들어오는 경로가 없다.

DestinationProfileValidator는 claim check를 알고 있다 — profile.payload().claimCheckThresholdBytes() > profile.payload().maxBytes()를 거부한다. 즉 프로파일은 claim check 임계값을 선언할 수 있고 검증도 받지만, 그 임계값을 넘는 payload에 대해 claim check를 수행하는 코드가 조립되지 않는다. 임계값은 설정 가능하고 효과는 없다.

§7.1보다 낮은 P3으로 두는 이유는 claim check가 outbox와 달리 부재 시 동작이 명확하기 때문이다 — payload가 그대로 전송되고, 크기 한도(BoundedByteSink, §4.1)에 걸리면 MessageTooLargeException으로 명시적으로 실패한다. 조용한 잘못된 성공이 아니라 시끄러운 실패다.


8. sub-scope 06 — admin (48 파일)

leaf 출하? main test main LOC
messaging-admin-api 출하 25 1 1,613
messaging-admin-runtime 출하 12 6 1,253
8.1 검증된 설계 — admin plane의 게이트가 이 가족에서 가장 잘 조립돼 있다

MessagingAdminAutoConfiguration:

@ConditionalOnProperty(prefix = "app.messaging.admin", name = "enabled", havingValue = "true")

matchIfMissing 없음 — 기본 꺼짐. javadoc:

"An application that acquires the admin plane by adding a starter to its classpath is exactly the situation the plane's guards exist to prevent — the guards would still refuse an unapproved operation, but the beans would be reachable from any code in the process."

세 가지가 특히 정확하다:

  1. DestructiveOperationGuard(false) — "an application runtime never holds an admin credential, so the guard refuses the operations that would need one. An operator tool overrides this bean with true."
  2. DestructiveMessagingAdmin은 의도적으로 bean이 아니다 — javadoc이 명시한다: "No bean for it is ever auto-configured: an operator tool that needs purge or delete registers one itself, with an admin credential this runtime does not hold." 부재를 문서화한 것이 이 가족에서 드물다.
  3. 비내구 journal + 시작 검증기 쌍InMemoryAdminOperationJournal이 기본값이고 isDurable()=false를 스스로 선언하며, MessagingAdminDurabilityValidator가 production 프로파일에서 그것을 거부한다:
throw new MessagingConfigurationException("ADMIN_JOURNAL_NOT_DURABLE",
    "the destructive-operation journal in use (" + journal.getClass().getSimpleName()
    + ") is not durable, and profiles " + active + " include a production profile; supply an "
    + "AdminOperationJournal bean backed by the shared database (JdbcAdminOperationJournal) so "
    + "one approval cannot be executed twice across replicas or across a restart");

메시지가 무엇을 공급해야 하는지 클래스 이름으로 말한다. 그리고 InitializingBean.afterPropertiesSet으로 돌아 컨텍스트 구성 중에 실패한다(§3.5의 StartupProfileValidation과 같은 패턴). javadoc이 이전 상태를 적는다 — "The previous default was an in-memory map registered by this starter, and nothing in the application said so. Two replicas each had their own copy, so both could claim the same approval at the same moment; a restart forgot every claim... Both failures look like a successful redrive until somebody counts the duplicates."

이 세 개가 §5.2(BrokerAclManifest의 startup 자기점검이 없음)와 정확히 대비된다. 같은 저장소, 같은 가족에서 하나는 완성됐고 하나는 안 됐다.

8.2 P2 — admin 스위치가 가드를 켜고 서비스는 켜지 않는다

app.messaging.admin.enabled=true가 만드는 bean은 넷이다: DestructiveOperationGuard, AdminOperationJournal, MessagingAdminDurabilityValidator, (BrokerTopologyInspector가 있을 때) CompositeTopologyValidator.

만들지 않는 것:

타입 leaf main 참조 부재가 문서화됐는가
DestructiveMessagingAdmin admin-runtime 2 — javadoc이 명시
DefaultMessagingAdminService admin-runtime 0 아니오
HmacApprovalVerifier admin-api 0 (test 4) 아니오
TopologyValidationRuntime admin-runtime 0 아니오
RedriveService / ReplayService admin-runtime 2 / 1 아니오

DefaultMessagingAdminServiceMessagingAdminService(인터페이스, main 참조 2)의 유일한 구현이다. 즉 admin plane을 켜도 admin 서비스가 없다.

HmacApprovalVerifierApprovalVerifier의 유일한 구현이고, 테스트 4개(ApprovalForgeryTest 포함)가 그것을 검증한다. ApprovedRedrivePlan/ApprovedReplayPlan/VerifiedApproval/PlanDigest(main 참조 10)로 이루어진 승인 사슬 전체가 검증자 없이는 시작될 수 없다.

부재의 등급이 넷 다 다르지 않은데 문서화는 하나만 됐다. DestructiveMessagingAdmin의 부재에는 명확한 이유가 있다("이 런타임은 admin 자격 증명을 갖지 않는다"). 나머지 넷에는 이유가 적혀 있지 않고, 그중 DefaultMessagingAdminServiceHmacApprovalVerifier는 파괴적이지 않은 admin 동작(redrive/replay의 승인·실행)에 필요한 것이다.

실패 시나리오. 운영 절차서(docs/messaging/retry-dlq-redrive.md)에 따라 사고 대응 중 redrive를 실행하려 한다. app.messaging.admin.enabled=true로 켠다. 부팅은 성공하고 가드·journal·durability 검증기가 올라온다. 그런데 MessagingAdminService bean이 없으므로 redrive를 호출할 대상이 없다. 사고 한가운데에서, 켰다고 생각한 기능이 없다는 것을 발견한다 — MessagingAdminDurabilityValidator의 javadoc이 경계한 상황("the gap only shows up during the incident the operation was run to resolve, which is the worst possible moment to discover it")과 정확히 같은 시점이다.

8.3 P3 — messaging-admin-api는 main 25파일 · 1,613 LOC에 테스트 파일이 1개다
messaging-admin-api      main=25  test=1   (DestructiveOperationGuardTest)
messaging-admin-runtime  main=12  test=6

messaging-admin-api가 담고 있는 것은 승인·다이제스트·토폴로지 계약이다 — ApprovalVerifier, HmacApprovalVerifier, ApprovalGrant, VerifiedApproval, PlanDigest, ApprovedRedrivePlan, ApprovedReplayPlan, DestructiveOperation, TopologyManifest, TopologyValidationReport 등 보안에 직결되는 타입들이다.

다만 이것은 보이는 것보다 덜 나쁘다. 실제 검증은 messaging-admin-runtime의 테스트 6개가 수행한다 — ApprovalForgeryTest(위조 승인 거부), ApprovedPlanExecutionTest, RedriveResumptionTest, AdminOperationJournalTest, TopologyValidationRuntimeTest, TopologyValidatorTest. HmacApprovalVerifier의 test 참조가 4건인 것이 그 증거다.

즉 계약 leaf에 테스트가 없는 것이 아니라 테스트가 소비자 leaf에 있다. 계약 자체의 경계 조건(예: PlanDigest의 정규화, ApprovalGrant의 만료)이 별도로 고정돼 있는지는 확인되지 않는다. 기록으로 남긴다.

8.4 검증된 설계 — actuator 엔드포인트가 읽기 전용이고 재식별 표면을 만들지 않는다

MessagingEndpoint(@Endpoint(id = "messaging")):

"Read-only. An actuator endpoint is reachable by anything that can reach the management port, so a write operation here would be an unauthenticated pause or purge in most deployments — the admin plane exists for those, with approvals and an audit trail this endpoint deliberately does not duplicate.

What it reports is bounded and free of per-message identity: destination names, guarantees, capability tiers, and in-flight counts. No message ids, no keys, no payloads — an actuator response is a diagnostic surface, and a diagnostic surface that echoes message content is a re-identification surface too."

그리고 in-flight 카운트의 출처를 바로잡은 이력이 있다 — "They used to come from a second limiter that no publish ever touched, so the endpoint reported zero in flight under any load — a diagnostic that is worse than absent, because it looks like an answer."

이 문장이 §5.1의 관측 결함을 판정하는 기준이기도 하다. 다만 §5.1과 달리 여기서는 카운트가 실제 게이트(MessagingAdmissionController, main 참조 4)에서 온다.


9. sub-scope 07 — assembly · testkit · 가족 거버넌스 (68 파일)

대상 출하? main test main LOC
messaging-spring-boot-starter 출하 28 10 3,528
messaging-testkit build-only 13 6 1,109
src/messaging/CLAUDE.md 132줄
9.1 검증된 설계 — 설정 위생 3층

starter가 세 개의 설정 검증기를 전부 bean으로 등록한다(MessagingCoreAutoConfiguration:52·72·106). 셋 다 InitializingBean이므로 컨텍스트 구성 중에 실패한다.

(1) MessagingPrefixMigrationValidator — 죽은 prefix를 거부한다.

"There were three namespaces. The starter's binder read backend.messaging; the deployed runtime and its APP_MESSAGING_* environment variables read app.messaging; and the configuration reference documented a bare messaging ... Two of the three bound nothing, so a deployment configured exactly as documented changed no behaviour and produced no error — the settings were simply inert."

그리고 alias를 만들지 않기로 한 결정도 기록돼 있다(MessagingSettings:19) — "An alias that works is a fourth thing to keep in sync."

(2) MessagingConfigurationKeyValidator — 바인딩되는 섹션 안의 오타를 거부한다.

"A misspelt prefix is loud — the whole section is missing and someone notices. A misspelt key inside a section that does bind is the opposite: the entry appears, the platform starts, and the one setting the operator came to change is the only one that did not take. consumer.prefech: 64 is a throughput change that never happened, and the first evidence is the incident it was supposed to prevent."

그리고 적법 키를 settings record에서 파생한다 — "A list would be [a second thing to keep in sync]". 이것이 모듈 18의 env-keys.yaml(수동 목록 + 삼자 일치 테스트)보다 한 단계 위의 접근이다.

(3) MessagingCredentialRequirementValidator — production 프로파일에 자격 증명 출처가 없을 때 행동 가능한 오류 하나로 실패한다.

"An application that enabled the starter without one therefore failed on 'no qualifying bean of type CredentialRuntimeRegistry' — a message about this platform's internals, naming a type the operator has never heard of, when the thing they have to do is supply a credential source."

범위가 production 프로파일로 좁혀진 이유도 명시된다 — "A local profile that connects to a broker with no authentication is a legitimate development setup; the same profile marked production is not, and the two must not be satisfiable by the same configuration."

이 셋 + §8.1의 MessagingAdminDurabilityValidator + §3.5의 두 StartupProfileValidation으로, starter는 6개의 시작 시점 검증기를 실제로 돌린다. 이 저장소에서 가장 촘촘한 조립 검증 층이다.

9.2 검증된 설계 — 꺼진 상태가 계약으로 고정돼 있다

MessagingPlatformRootAutoConfiguration의 javadoc:

"app.messaging.enabled=false means no beans, no clients, no threads, and no bound detail namespace. MessagingStarterOffContractTest holds that against a full context including this starter's imports and the vendor auto-configurations Spring Boot would contribute."

"vendor 자동설정까지 포함한 전체 컨텍스트에서" 검증한다는 점이 중요하다 — 마스터 스위치가 자기 자식만 막고 Spring Boot가 기여하는 Kafka/Rabbit 자동설정은 놔두는 상태가 흔한 실패인데, 그것을 테스트가 붙들고 있다.

9.3 P2 — 문서 계약 테스트가 존재하고, 그 커버리지 경계가 §6.3·§6.4의 드리프트 위치를 정확히 예측한다

MessagingDocumentationContractTest(testkit)가 존재한다. 클래스 javadoc이 목적을 정확히 적는다:

"Docs rot silently. A support matrix claiming an adapter is Stable outlives the day someone demoted it, and nothing fails — the tests still pass, the build is green, and the only signal is an operator making a decision on a page that stopped being true months ago.

The assertions are deliberately narrow: they check the claims a reader would act on, not prose. Asserting on wording would make every edit a test failure and the check would be deleted."

단언 8개:

# 테스트 무엇을 붙드는가 결과
1 everyDocumentTheSupportMatrixPromisesExists 9개 문서 존재
2 theSupportMatrixNamesExactlyTheAdaptersTheCodeCallsStable STABLE 어댑터 이름
3 theSupportMatrixDoesNotCallAnExperimentalAdapterStable EXPERIMENTAL이 Stable로 적히지 않음
4 theDocumentedKafkaVersionsMatchWhatTheCodeCertifies Kafka 버전 문자열
5 theUnsupportedListStillNamesTheTwoConstantsThatDoNotExist EXACTLY_ONCE·GLOBAL 언급
6 noEnumConstantTheDocsDenyActuallyExists 그 두 상수가 코드에 없음
7 theExperimentalPolicyStatesThatExperimentalIsOffByDefault contains("false") 약함
8 everyDocumentHasContent 각 문서 500자 초과 약함

커버리지 밖에 있는 것:

  • capability 표(5 어댑터 × 12 플래그 = 60칸) — 여기에 §6.3의 단 하나의 오류가 있다.
  • runtime membership 문장 — 여기에 §6.4의 드리프트가 있다.
  • 브로커 등급 표의 "제한" 칸 — 여기에 §6.2(Rabbit이 선택 불가라는 사실의 누락)가 있다.

즉 이 문서에서 발견한 문서 드리프트 3건 전부가 이 테스트의 단언 8개가 닿지 않는 곳에 있다. 우연이 아니다 — 테스트가 붙드는 항목(등급 이름, 버전 문자열, 존재하지 않는 상수)은 정확히 옳고, 붙들지 않는 항목만 틀렸다.

이것이 이 테스트를 결함으로 만들지는 않는다. javadoc이 "deliberately narrow"라고 밝혔고, 그 선택에는 이유가 있다("Asserting on wording would make every edit a test failure and the check would be deleted"). 결함은 그 경계가 어디인지가 문서에도 테스트에도 적혀 있지 않다는 것이다 — 이 테스트를 통과한 문서가 "코드와 일치하도록 검증됐다"로 읽힌다.

두 개의 약한 단언도 같은 성질이다. theExperimentalPolicyStatesThatExperimentalIsOffByDefault가 실제로 하는 것은 read("experimental-policy.md").contains("false")다 — 문서 어디엔가 "false"라는 문자열이 있으면 통과한다. 이름이 약속하는 것("experimental이 기본 꺼짐이라고 명시한다")과 검증하는 것의 거리가 이 가족의 다른 테스트들과 다르다.

capability 표는 기계로 검증 가능하다. CompatibilityMatrix.entries()가 어댑터 이름을 갖고 있고, 각 어댑터의 MessagingCapabilities는 코드 상수이며, 표는 마크다운 파이프 테이블이다. #2·#4가 하는 것과 같은 방식으로 대조할 수 있다.

증거: 261-messaging-documentation-contract-test-coverage.txt

9.4 P3/기록 — 가족 권위 문서가 자기 드리프트를 고친 방식

src/messaging/CLAUDE.md는 이 저장소에서 읽은 가족 문서 중 가장 규율이 있다.

  • 첫 문장이 SSOT 경계를 정한다 — "leaf 목록·gradle path·허용 의존성은 modules.json이 SSOT이며 이 문서는 그것을 복제하지 않는다."
  • Family 규칙 표가 leaf id가 아니라 역할로 정의된다 — "family는 디렉터리 이름의 접두사가 아니라 아래 표의 역할로 정해진다."
  • 이름 결정의 근거를 남긴다 — outbox-jdbc-postgresql로 개명한 이유("jpa라는 이름은 사용하지 않는 기술을 광고하고, vendor 중립 port가 어디 있는지도 숨겼다").
  • 자기 오류를 고친 방식이 정확하다 — "이 절은 한동안 사실이 아닌 채로 남아 있었다… 정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다."

마지막 문장이 §6.4의 해법이기도 하다. docs/messaging/support-matrix.md가 아직 세고 있고, 그래서 아직 틀려 있다.

한 가지 정정할 점(§6.5): CLAUDE.md는 MSG-015의 현재 안전 근거를 app.messaging.enabled=false에 둔다. 그러나 app-bootstrap의 seam 스택은 enabled가 아니라 app.messaging.broker만 본다. 기본값이 지켜 주는 범위가 문서가 말하는 것보다 좁다.

9.5 P3 — MessagingPublicSurfaceContractTest가 가족 밖(app-bootstrap)에 있다

CLAUDE.md의 public surface 정책이 "이 규칙은 문서가 아니라 MessagingPublicSurfaceContractTest가 붙들고 있다"고 말한다. 그 테스트의 위치:

src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingPublicSurfaceContractTest.java

src/messaging/** 밖이다. 결과:

  • messaging leaf만 바꾸고 :messaging:*:test만 돌리면 이 계약은 검증되지 않는다.
  • CI의 messaging-certification 워크플로는 src/messaging/** PR에서 돌지만 그것은 Kafka 인증 레인이고 이 테스트가 아니다.

api/implementation 분리는 이 가족의 정책이고 위반은 이 가족의 build.gradle에서 발생하는데, 그것을 잡는 테스트는 합성 루트의 레인에 있다. 모듈 18 §4.1c가 기록한 것과 같은 형태 — "A gate that is red in a lane nobody runs locally is a gate that reports whatever the last person to run it saw." 여기서는 레인이 다르다는 정도이므로 P3이다.


10. 네 가지 필수 negative-space 탐침

10.1 §8.1 도달성 — 조립 지점이 없는 main 타입

출하 leaf에 한정한 결과(main 참조 0건, 선언 파일 자신 제외):

타입 leaf 무엇이 실행되지 않는가
KafkaTransactionProfileValidator kafka Kafka 트랜잭션 5개 전제 조건 검사 §3.5
SchemaCompatibilityValidator schema-api NONE_EXPERIMENTAL production 거부 + 비교 대상 버전 산출 §4.3
SchemaRegistry (구현 없음) schema-api 스키마 이력의 출처 §4.3
RawBytesMessageCodec schema-api (기본 코덱 금지 대상 — 의도적) §4.4
DefaultCloudEventMapper · CloudEventMapper · CloudEventExtensions cloudevents CloudEvents 상호운용 전체 §4.5
MessagingMetrics · MessagingTracer · MessagingAuditSink · DefaultMessagingObservationConvention observability 관측 전체 §5.1
BrokerAclManifest security ACL 자기 점검 + 파괴적 권한 거부 §5.2
DestinationAccessValidator security (중복 장치 — 다른 경로가 검사 수행) §5.3
CredentialRotationPlan security (중복 장치 — 레지스트리가 회전 수행) §5.4
JdbcOutboxRepository · JdbcInboxRepository outbox/inbox-jdbc outbox/inbox 체인 전체 §7.1
DebeziumOutboxRecordMapper outbox-jdbc Debezium CDC 경로 §7.1
ClaimCheckPublisher · ClaimCheckResolver claim-check claim check 전체 §7.4
HmacApprovalVerifier admin-api 승인 검증 (유일 구현) §8.2
DefaultMessagingAdminService admin-runtime admin 서비스 (유일 구현) §8.2
TopologyValidationRuntime admin-runtime 토폴로지 검증 실행 §8.2

build-only leaf의 미도달(PulsarProfileValidator, NatsJetStreamProfileValidator, KafkaShareProfileValidator, BindingProfileValidator, avro/protobuf 코덱)은 등급과 일치하므로 이 표에 넣지 않았다.

10.2 §8.2 조건부 형제 비교
형제 쌍 차이 판정
KafkaProfileValidator vs KafkaTransactionProfileValidator 같은 자동설정 클래스, 하나는 StartupProfileValidation으로 감싸고 하나는 @Bean P2 §3.5
messaging-schema-json(출하, 게이트 없음) vs -avro/-protobuf(build-only, 게이트 있음) 호환성 게이트가 출하되지 않는 쪽에만 P2 §4.4
MessagingRedactor·CardinalityGuard(bean 있음) vs MessagingMetrics·MessagingTracer(bean 없음) 협력자는 조립, 방출자는 미조립 P2 §5.1
Kafka(transport bean 있음) vs Rabbit(transport bean 없음, 선택 거부) 코드는 대칭적으로 공개, 문서만 비대칭 P2 §6.2
DestructiveMessagingAdmin(부재가 문서화됨) vs DefaultMessagingAdminService·HmacApprovalVerifier(부재가 문서화 안 됨) 같은 등급의 부재 4건 중 1건만 설명 P2 §8.2
Kafka 헤더 위조 가드 vs Rabbit 헤더 위조 가드 차이 없음 — 구현·주석·테스트 모두 대칭 결함 아님 §6.8
MessagingCoreAutoConfigurationMessageCodecRegistry 기본 제공 vs MessagingReliabilityAutoConfigurationOutboxRepository 미제공 한쪽은 @ConditionalOnMissingBean으로 기본 구현, 한쪽은 만족되지 않는 @ConditionalOnBean P2 §7.1
10.3 §8.3 중복 장치 쓸기
개념 장치 A (조립됨) 장치 B (미조립) 판정
destination 접근 검사 DefaultMessagePublisher.access.mayPublishREJECTED/CONFIGURATION DestinationAccessValidatorMessageAuthorizationException P3 §5.3 — 미조립 쪽이 분류가 더 정확
자격 증명 회전 CredentialRuntimeRegistry + CredentialRuntime.isDueForRotation (동시성 계약 테스트 있음) CredentialRotationPlan.isDue (참조 0) P3 §5.4 — 조립된 쪽이 더 나음
Kafka producer messagingKafkaProducer(platform, byte[]) kafkaSeamProducer(bootstrap seam, String) — 둘 다 조립됨 P2 §6.5 — 둘 다 살아 있는 중복
인증 증거 검증 verifyMessagingCertificationEvidence(Gradle, 실행 산출물 대조) CertifiedEvidenceTest.everyRecordedScenarioIsALineTheCertificationLaneWrote(같은 파일 두 번 읽음) P3/기록 §3.7
지원 등급의 출처 CompatibilityMatrix.hasLiveBrokerCertification()(증거 파생) (이전) tier 옆의 boolean 필드 — 이미 제거됨 해결됨 §6.6
10.4 §8.4 문서·카운트 드리프트
# 주장 실제 판정
1 support-matrix.md: Kafka deduplicatedPublish = O KafkaMessagingTransport.CAPABILITIES[9] = false (javadoc이 true→false 변경 이력과 이유를 서술) P1 §6.3
2 support-matrix.md: "registry의 messaging leaf는 모두 runtime_memberships가 비어 있다" 출하 18 / build-only 7. src/messaging/CLAUDE.md가 이미 이 문장이 틀렸다고 기록 P2 §6.4
3 support-matrix.md RabbitMQ 행의 "제한" 칸이 "장애 시나리오 미실행"만 언급 BROKERS_WITHOUT_A_TRANSPORT에 등록 — 선택 자체가 startup 오류 P2 §6.2
4 MessagingCapabilities javadoc: "the platform fails loudly — at startup where possible" 12개 중 거부하는 것 1개, 그것도 publish 시점 P2 §3.4
5 BrokerAclManifest javadoc: "checks itself against at startup" / "rejected outright" 두 검사 모두 존재하지 않음 P2 §5.2
6 NatsJetStreamTransport javadoc: "NatsJetStreamProfileValidator refuses the combination at startup" 그 validator를 생성하는 코드가 없음 (build-only leaf) P3 §3.5
7 인증 manifest의 gitCommit: e98b56eb03ec… vs 분석 리비전 a24ece9c… 게이트가 gitCommit·observedAt을 제거한 뒤 비교 — 설계상 정상 결함 아님 §2.3
8 messaging-cloudevents = "schema" 계열로 보이는 이름 등록 id는 messaging-cloudevents(schema 접두사 없음), 디스크의 -inbox-jpa/-outbox-jpa는 추적 파일 0 기록 §1.1
9 CompatibilityMatrix.Tier.EXTENSION 사용하는 항목 0개. 문서에는 Artemis/JMS 행이 있고, 실재하는 bridge leaf는 양쪽 어디에도 없음 P3 §6.7
10 MessagingDocumentationContractTest 단언 8개 발견된 문서 드리프트 3건 전부가 커버리지 밖 P2 §9.3

11. 발견 종합 — P1 1건 · P2 14건 · P3 10건

심각도 발견 위치
P1 6.3 지원 매트릭스가 Kafka deduplicatedPublishO로 적고 코드는 false이며, 그 믿음이 코드 javadoc이 지목한 피해다 docs/messaging/support-matrix.md · KafkaMessagingTransport:62
P2 3.4 capability 12개 중 main이 읽는 것 3개, 거부하는 것 1개 — javadoc의 "fails loudly"가 성립하지 않음 MessagingCapabilities · DefaultMessagePublisher:250
P2 3.5 출하 leaf의 KafkaTransactionProfileValidator@Bean만 있고 호출되지 않음 KafkaMessagingAutoConfiguration:74
P2 4.3 SchemaCompatibilityValidator 미호출 + SchemaRegistry main 구현 없음 → NONE_EXPERIMENTAL이 production으로 통과 messaging-schema-api
P2 4.4 호환성 게이트가 build-only 포맷에만 있고 출하되는 JSON 코덱에는 없음 schema-avro/-protobuf vs schema-json
P2 4.5 messaging-cloudevents가 출하 leaf이자 starter 의존인데 소비자 0 messaging-cloudevents
P2 5.1 출하 publish 경로가 NO_OBSERVATION으로 조립 — 메트릭·트레이스·감사 전부 없음 MessagingCoreAutoConfiguration:445
P2 5.2 BrokerAclManifest의 startup 자기 점검·파괴적 권한 거부가 존재하지 않음 messaging-security
P2 6.2 Rabbit이 선택 불가(BROKERS_WITHOUT_A_TRANSPORT)라는 사실이 운영 문서에 없음 support-matrix.md
P2 6.4 운영 문서가 "모든 leaf build-only"라고 적음 — 가족 권위 문서는 이미 정정 support-matrix.md
P2 6.5 서로 모르는 Kafka 스택 두 개(MSG-015). seam은 enabled를 보지 않아 기본값 보호 범위가 문서보다 좁음 KafkaSenderConfig:38
P2 7.1 outbox/inbox 체인 전체가 만족되지 않는 @ConditionalOnBean 뒤 — 2,818 LOC 무효 MessagingReliabilityAutoConfiguration
P2 7.2 messaging 마이그레이션 스트림을 적용하는 곳이 없고, 적용하면 V2 중복으로 실패 db/migration/messaging/
P2 8.2 admin 스위치가 가드 4개를 켜고 서비스·승인 검증기는 켜지 않음 MessagingAdminAutoConfiguration
P2 9.3 문서 계약 테스트의 커버리지 밖에 문서 드리프트 3건이 전부 위치 MessagingDocumentationContractTest
P3 3.6 messaging-reliability-api main 13파일·817 LOC에 테스트 0 messaging-reliability-api
P3 3.7 everyRecordedScenarioIsALineTheCertificationLaneWrote가 같은 파일을 두 경로로 비교 CertifiedEvidenceTest
P3 5.3 접근 검사 중복 — 조립된 쪽이 인가 거부를 CONFIGURATION으로 분류 §5.3
P3 5.4 자격 증명 회전 개념 중복, 하나는 참조 0 CredentialRotationPlan
P3 5.6 유출 편집기는 bean이지만 편집 대상 경로가 미조립 MessagingRedactor
P3 6.7 Tier.EXTENSION 미사용 + bridge leaf가 매트릭스 밖 CompatibilityMatrix
P3 6.9 experimental 3종의 "AdapterContractTest"가 공유 계약을 돌리지 않음(선언과는 일치) pulsar/nats
P3 7.4 claim check 배선 0 — 임계값은 설정·검증되지만 효과 없음 messaging-claim-check
P3 8.3 messaging-admin-api main 25파일·1,613 LOC에 테스트 1개(검증은 소비자 leaf에 있음) messaging-admin-api
P3 9.5 MessagingPublicSurfaceContractTest가 가족 밖 레인에 있음 app-bootstrap
11.1 이 가족에서 검증된(결함 아님) 설계 — 12건

앞선 18개 모듈과 달리, 이 가족에서는 공개된 주장이 실제로 성립하는 항목이 다수다. 기록해 둔다.

  1. MSG-022 — 예외 타입 문자열 판별 0건 (§2.1)
  2. fault scenario 커버리지 — 미커버 항목이 knownGaps파생되고 이유까지 단언됨 (§2.2)
  3. 인증 게이트 — 실행 산출물 ↔ 커밋 manifest 양방향 대조, 캐시 금지, CI 연결, Docker 가드 의도적 부재 (§2.3)
  4. 단일 publish 경로 — 고정 순서, 각 단계 위치의 근거, 모든 경로에서 정확히 한 번 반환 (§3.1)
  5. 증거 우선 결론 후행PublishEvidence compact constructor가 불가능한 조합 거부, 확정/모호 구분 (§3.2)
  6. caller 소유 데드라인 — 호출 시점부터 측정, 복사본에 timeout (§3.3)
  7. BoundedByteSink — 한도를 넘는 write에서 실패하는 할당 경계 (§4.1)
  8. RegisteredMessageCodecs — 기본 코덱 명시 필수, raw-bytes 기본 금지, content type 중복 거부, 계약 카탈로그 fail-closed (§4.2)
  9. 전송 선택 — classpath가 아니라 속성, 닫힌 레지스트리, 세 가지 startup 오류, 단일 master switch (§6.1)
  10. 등급의 증거 파생hasLiveBrokerCertification()이 boolean 필드가 아님, 9개 규칙 테스트 (§6.6)
  11. 예약 헤더 위조 방어 — 두 출하 어댑터에서 구현·근거·테스트 모두 대칭 (§6.8)
  12. admin plane 게이트 + 설정 위생 6층 — 기본 꺼짐, 비파괴 기본값, 부재의 문서화, 비내구 journal 거부, prefix/key/credential 검증기 (§8.1, §9.1)
11.2 이 가족이 앞선 18개 모듈과 다른 점

지금까지 반복해서 기록한 형태는 **"장치는 있고 회로가 닫히지 않았다"**였다. messaging에서도 그 형태가 15개 타입에 나타난다(§10.1). 그러나 원인이 다르다.

앞선 모듈들에서 회로가 열려 있던 이유는 조립하는 쪽이 그 장치를 몰랐기 때문이었다(모듈 14 §8.1 — 합성 루트가 다섯 패키지를 스캔에서 빼고 넘겨받는 자동설정을 만들지 않았다). messaging에서는 조립하는 쪽이 있고, 잘 만들어져 있으며, 일부를 물지 않았다. MessagingCoreAutoConfiguration은 28개 클래스 중 하나이고 6개의 시작 검증기를 돌리며 MessageCodecRegistry·DestinationProfileRegistry·DestinationAccessPolicy를 직접 제공한다. 같은 클래스가 MessagingObservation은 no-op로 두고(§5.1), OutboxRepository@ConditionalOnBean으로 남긴다(§7.1).

그래서 이 가족의 결함은 누락이 아니라 선택의 비일관이다. 그리고 그 비일관이 어디에 있는지를 판정하는 기준을 이 가족 자신이 제공한다 — StartupProfileValidation의 javadoc("were all beans and none of them was injected anywhere"), DefaultMessagePublisher의 javadoc("existed as beans that no publish ever called"), RegisteredMessageCodecs의 javadoc("an interface with no implementation anywhere"). 세 문장이 같은 결함 유형을 이름으로 부르고 있고, 이 문서의 §10.1 표는 그 유형이 아직 15곳에 남아 있다는 목록이다.


12. 검증

12.1 테스트 레인

25개 messaging leaf의 :test 전량 실행:

./gradlew :messaging:messaging-admin-api:test :messaging:messaging-admin-runtime:test \
          ... (25개 전량) --console=plain

BUILD SUCCESSFUL in 2m 27s
105 actionable tasks: 59 executed, 46 up-to-date
GRADLE_EXIT=0

증거: 263-messaging-test-lane-result.txt

돌지 않은 레인(환경 제약, 결함 아님):

  • messagingCertificationTest / verifyMessagingCertificationEvidence — Docker 컨테이너 필요. 이 레인은 의도적으로 Docker 가드가 없어 컨테이너 없이는 실패한다(§2.3). 컨테이너 안에서 분석 중이므로 실행하지 않았다.
  • *PostgresIT, *BrokerIT, KafkaAmbiguityChaosIT 등 Testcontainers 계열 — DockerAvailability 가드로 skip된다.
12.2 소스 트리 변경 없음
git status --short   →   (출력 없음)

문서 파이프라인은 애플리케이션 소스를 수정하지 않았다.

12.3 커버리지 원장 최종
sub-scope leaf 파일 FULL_READ STRUCTURAL_ONLY
01 core contracts 4 141 DefaultMessagePublisher, PublishEvidence, ConfirmationLevel, MessagingCapabilities, CapabilityRegistry, DestinationCapabilities, transport-spi 13, reliability-api 13, runtime-core 6 core-api error 26 · delivery 13 · header 5 등
02 schema 5 41 SchemaCompatibilityValidator, BoundedByteSink, RegisteredMessageCodecs, 4개 코덱 헤더 Avro/Protobuf 내부
03 policy·security·observability 3 66 DestinationProfileValidator(16 규칙), MessagingMetrics 헤더, BrokerAclManifest, DestinationAccessValidator, CredentialRotationPlan, CredentialRuntimeRegistry policy 26 중 재시도/DLQ 내부
04 brokers 6 134 KafkaMessagingTransport, RabbitMessagingTransport, 두 HeaderMapper 전문, KafkaPublishMapper, RabbitConfirmCoordinator, MessagingProviderSelection, CompatibilityMatrix Kafka 34 중 재시도/트랜잭션 내부, 실험 어댑터 3종
05 reliability stores 3 52 MessagingReliabilityAutoConfiguration 조건 사슬, 마이그레이션 5개 헤더 JDBC 구현 내부
06 admin 2 48 MessagingAdminAutoConfiguration, MessagingAdminDurabilityValidator, MessagingEndpoint admin-api 25 중 승인 타입 내부
07 assembly·testkit 2+1 68 MessagingPlatformRootAutoConfiguration, 3개 설정 검증기, CertifiedEvidence, NetworkFaultScenario, MessagingDocumentationContractTest, CLAUDE.md 전문 starter 28 중 facade 3종
25 550

EXCLUDED: 없음. 미배정: 0.

12.4 증거

evidence/raw/236263 (28개 파일). 이번 문서에서 새로 생성한 것은 243263(21개)이다.


A20. grpc-platform

분석 중에는 20-grpc-platform.md 파일이었다. 716줄.

20. gRPC platform family — 18 leaf 통합 분석

  • 분석 대상 리비전: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 (feat: grpc 기능 deep 구현, 2026-08-31)
  • 직전 기준선: a24ece9cf797f7ea647e33bf846b115208ed1ba5 — 모듈 01~19의 분석 리비전
  • 범위: src/grpc/**(12 leaf) + src/grpc-advanced/**(6 leaf) — modules.json 등록 18개
  • 분모: git 추적 파일 383개 (main Java 260 / 18,726 LOC · test Java 68 · 나머지 build/lockfile/resource/governance)
  • 가족 로컬 권위 문서: src/grpc/CLAUDE.md(117줄) · src/grpc-advanced/CLAUDE.md(77줄)
  • 테스트 레인: 18개 leaf :test 전량 + 증거 레인 3종 — 전부 BUILD SUCCESSFUL

0. 이 문서가 왜 20번인가 — 분석 도중 코드베이스가 이동했다

교차 스코프 분석을 시작하며 레지스트리를 다시 전수로 읽었을 때 등록 모듈이 62개인데 state.json이 추적하던 것은 44개였다. 누락 18개는 전부 gRPC 가족이다.

원인은 분석 누락이 아니라 리비전 이동이다:

$ git log --oneline -3
21234e38 feat: grpc 기능 deep 구현        <- 현재 HEAD (2026-08-31)
a24ece9c feat: web, websocket 어댑터 추가 구현   <- 모듈 01~19의 분석 기준선
01372634 refactor: 각 어댑터터별 리펙토링 진행

$ git diff --stat a24ece9c..HEAD
400 files changed, 40217 insertions(+), 4 deletions(-)

변경 경로는 src/grpc/** · src/grpc-advanced/** · modules.json(18개 항목 추가) · src/build.gradle(테스트 클래스패스 조건에 :grpc:·:grpc-advanced: 추가) · docs 15개뿐이다. src/messaging/ 이하는 한 줄도 바뀌지 않았고, 모듈 01~19가 다룬 어떤 경로도 변경되지 않았다(삭제 4줄은 src/build.gradle의 주석 교체분이다). 따라서 앞선 19개 문서와 그 증거는 그대로 유효하다.

state.jsongitRevision을 HEAD로 올리고 reanalysis 블록을 ADDITIVE_SCOPE로 채웠으며, 18개 스코프를 추가해 이 문서로 닫는다.


1. 분모와 커버리지 원장

1.1 등록 leaf 18개
# leaf 파일 main test main LOC 허용 의존 runtime_memberships
1 grpc-policy 82 62 18 4,781 1 []
2 grpc-core-api 41 32 7 1,897 0 []
3 grpc-testkit 36 26 8 2,313 10 []
4 grpc-advanced-compat 25 17 5 962 5 []
5 grpc-server 24 17 5 1,106 2 []
6 grpc-advanced-resilience 23 16 4 940 5 []
7 grpc-advanced-streaming 20 14 4 833 3 []
8 grpc-admin 19 13 4 913 2 []
9 grpc-client 19 13 4 931 2 []
10 grpc-codegen 16 10 4 725 2 []
11 grpc-advanced-bootstrap 13 9 2 610 1 []
12 grpc-advanced-edition 11 6 2 326 3 []
13 grpc-discovery 11 7 2 409 2 []
14 grpc-proto-contract 11 3 1 605 1 []
15 grpc-advanced-diagnostics 8 4 1 277 3 []
16 grpc-spring-boot-starter 8 4 1 468 10 []
17 grpc-observability 7 4 1 354 1 []
18 grpc-operation-ledger-jpa 7 3 1 276 1 []
합계 381 260 74 18,726

분모 383 = leaf 381 + 가족 공통 문서 2개(src/grpc/CLAUDE.md, src/grpc-advanced/CLAUDE.md). 미배정 0.

18개 전부 runtime_memberships: [] — 가족 전체가 build-only다. 이것이 이 문서의 심각도 축이다(모듈 17 §26.6·모듈 19 §1.1의 원칙 적용). 어떤 배포 아티팩트도 이 코드를 싣고 있지 않으므로, 조립 결함은 오늘의 사고가 아니라 채택 시점의 부채로 기록한다.

1.2 sub-scope 분할
# sub-scope leaf 파일
01 core contracts core-api · proto-contract · codegen 68
02 policy policy 82
03 server · client · discovery server · client · discovery 54
04 admin · observability · ledger · 조립 경계 admin · observability · operation-ledger-jpa · spring-boot-starter 41
05 testkit · 증거 등급 · 릴리스 게이트 testkit + src/grpc/CLAUDE.md 37
06 advanced 6 leaf + src/grpc-advanced/CLAUDE.md 101
합계 18 leaf 383

2. 이 가족이 공개한 주장과 검증 결과

src/grpc/CLAUDE.md는 기계로 검사 가능한 주장을 여러 개 한다. 모듈 19에서와 같이 검증을 먼저 했다.

2.1 "grpc-core-api는 io.grpc를 이름조차 부르지 않는다" → 성립
grep -rn "io\.grpc"              grpc-core-api/src/main → 3
grep -rn "org\.springframework"  grpc-core-api/src/main → 0
grep -rn "com\.google\.protobuf" grpc-core-api/src/main → 0
grep -rn "jakarta\.persistence"  grpc-core-api/src/main → 0
(test 소스는 네 패턴 모두 0)

3건은 전부 javadoc 산문이고, 그 내용이 왜 타입을 쓰지 않는지를 설명한다:

"The canonical gRPC status codes, mirrored so that grpc-core-api stays free of io.grpc. ... a failure context that names io.grpc.Status would put the transport inside the contract that exists to describe what the transport did."

그리고 결정적으로 grpc-core-api/build.gradle이 이렇다:

apply plugin: 'java-library'
dependencies {
}

의존성 블록이 비어 있다. io.grpc가 컴파일 클래스패스에 아예 없으므로 이 제약은 문서가 아니라 빌드가 강제한다. 32개 main 파일 1,897 LOC가 Java stdlib만으로 서 있다.

2.2 "Stable leaf는 :grpc-advanced:*를 참조하지 않는다" → 성립
  • 레지스트리: 비-advanced leaf의 allowed_dependencies에 advanced id가 등장하는 경우 0건.
  • grpc-spring-boot-starterallowed_dependencies = Stable 10개 leaf뿐.
  • 소스: src/grpc 전체에서 dev.caskeleton.grpc.advanced 참조 0건.
  • 문서가 말하는 이중 강제: verifyCleanArchitectureDependencies(build time) + GrpcStableBuildInvariant·GrpcAdvancedModuleGuard.requireStableStarterIsClean(runtime).

단, runtime 절반은 실행되지 않는다. GrpcStableBuildInvariant를 호출하는 프로덕션 경로는 GrpcPlatformStartupValidator.validateAdvancedIsolation 하나이고, 그 validator 자체가 조립에서 호출되지 않는다(§3.1). 따라서 오늘 살아 있는 강제는 build time 한 층이다. 레지스트리 검사가 실효적이므로 규칙 자체는 성립하지만(위 세 확인), "runtime에도 같은 규칙을 강제한다"는 서술은 현재 상태를 서술하지 않는다.

grpc-advanced의 CLAUDE.md가 별도 디렉터리·별도 Gradle prefix를 쓴 이유를 명시한다 — "그 불변 조건을 registry의 allowed_dependencies만으로 기계 검증할 수 있게 하기 위해서."

2.3 "모든 grpc leaf의 runtime_memberships가 비어 있다" → 성립

18개 전부 []. 그리고 adapter-inbound-grpc(모듈 15에서 분석한 leaf)의 allowed_dependencies

["domain-core", "application-core", "shared-contract"]

— 이 가족을 볼 수 없다. CLAUDE.md가 "현재 adapter:inbound:grpc는 이 family에 의존하지 않는다 — registry의 allowed_dependencies를 보라"고 적은 그대로다.

이것이 messaging과의 결정적 차이다. messaging은 messaging-spring-boot-starterapp-bootstrap 의존으로 들어가면서 18개 leaf가 출하 아티팩트에 실렸고, 그 결과 §MSG-015(서로 모르는 두 스택)가 실재 문제가 됐다. gRPC 가족은 아직 그 선을 넘지 않았고, 넘지 않았다는 사실을 문서가 정확히 말한다.

2.4 "GrpcEvidenceGrade가 in-process 결과로 TLS를 주장하는 것을 거부한다" → 성립
public void requireCertifies(String capability) {
  Set<String> certified = certifies();
  if (!certified.contains(capability)) {
    throw new IllegalStateException(
        this + " evidence does not certify '" + capability + "'; it establishes " + ...);
  }
}

CONTRACT가 증명하는 것은 service-adapter·interceptor-order·status-mapping·validation·idempotency-replay·context-propagation 여섯이고, tls·mutual-tls·http2·goaway·keepalive 등은 TRANSPORT에만 있다. 등급별 집합을 필드가 아니라 switch로 계산하는 이유까지 적혀 있다 — "an enum with a collection field is a mutable enum as far as any static analysis can tell."

2.5 "performance lane은 기본 test에서 제외된다" → 성립
tasks.named('test') { useJUnitPlatform { excludeTags 'grpc-performance' } }

근거도 적혀 있다 — "a measurement in the release gate is a flaky test on a shared CI runner; it runs when somebody asks for it, by name."

2.6 지원 매트릭스가 자기 상태를 정확히 말한다 → 성립 (모듈 19와 정반대)

docs/compatibility/grpc-support-matrix.md:

"Not released. Every :grpc:* leaf is runtime_memberships: [] in the module registry, so the platform is build-only: it compiles, its lanes run, and no deployed artifact carries it."

그리고 미해결 릴리스 게이트 입력 두 개(성능 baseline 부재, protoc 미실행에 따른 스키마 codegen)를 스스로 나열한다.

모듈 19 §6.4는 정확히 이 문장의 반대 사례였다 — messaging의 지원 매트릭스는 "모든 leaf가 build-only"라고 적었지만 실제로는 18/25가 출하 중이었고, 가족 CLAUDE.md는 이미 그 문장이 틀렸다고 기록해 두었는데도 운영 문서는 고쳐지지 않았다. gRPC 쪽은 같은 문장이 사실이다.

GrpcCompatibilityMatrix.caSkeleton()의 7개 레인·등급도 문서 표와 전수 일치한다(certified 3 / compatibility 2 / watch 2).

판정: 이 가족의 공개된 주장 6건은 전부 성립한다. 모듈 19에 이어 두 번째 사례이고, grpc-core-api의 빈 dependencies {}는 이 저장소에서 본 가장 강한 형태의 자기 제약이다.


3. 발견

3.1 P2 — GrpcPlatformStartupValidator가 조립에서 호출되지 않는다

src/grpc/CLAUDE.md의 Stable 범위 절:

"GrpcPlatformStartupValidator가 Stable catalog에 streaming method가 등록되면 startup을 거부한다."

이 가족의 유일한 조립 지점은 GrpcPlatformAutoConfiguration이고(.imports 1줄), 그 클래스는 106줄에 @Bean 9개를 등록한다:

GrpcExecutorProfile · GrpcServerProfile · GrpcAdmissionController · GrpcServiceHealthRegistry ·
GrpcReflectionPolicy · GrpcAdminExposurePolicy · GrpcDrainPolicy · GrpcContextBinder · GrpcErrorMapper

GrpcPlatformStartupValidator(188줄)는 그 목록에 없다. 전수 참조:

grpc-spring-boot-starter/src/test/.../GrpcPlatformStartupValidatorTest.java   (12개 호출)
grpc-spring-boot-starter/src/main/.../GrpcPlatformStartupValidator.java       (선언 자신)

main 참조 0. 클래스는 final + private 생성자 + static 메서드(violations(...), requireValid(...))이므로 bean이 될 수도 없다 — 누군가 requireValid를 호출해야 하고, 호출하는 곳이 없다.

실행되지 않는 규칙이 13개다. validator 본문을 읽어 전수 확인했다:

그룹 규칙 거부 사유
transport·security 4 production 트래픽을 받을 수 없는 transport / 배포 환경에서 TLS 비활성 / 배포 환경에서 trustAllCertificates / 배포 환경에서 reflection ENABLED
executor 2 queue capacity < 1 / pool size < 1
methods 4 UNARY인데 deadline이 0 / explicitRetry가 idempotency 프로파일이 허용하지 않는 조합 / IDEMPOTENCY_KEY_REQUIRED인데 operation ledger 비활성 / rpcType이 Stable이 아님
channels 2 지원되지 않는 채널 프로파일 / in-process 재시도 소유자가 둘 이상
advanced isolation 1 Stable starter가 advanced 모듈을 해석함

클래스 javadoc이 13개를 고른 기준을 적는다:

"Every rule here is a mistake whose runtime symptom is either silence or a misattributed failure: a unary method with no deadline hangs until the client's, an unbounded executor turns overload into unbounded latency, trust-all in production reports TLS while providing none, reflection in production publishes the schema, and a keyed method without a ledger accepts idempotency keys it cannot honour. None of them fails a smoke test."

그리고 "Fails once with every violation, so a deployment learns the whole list in one restart" — 한 번에 전부 보고하도록 설계돼 있다.

CLAUDE.md가 인용한 "streaming method가 Stable catalog에 등록되면 거부"는 methods 그룹의 네 번째 규칙(!policy.rpcType().stable())이고, §2.2의 runtime 강제는 advanced isolation 그룹의 유일한 규칙이다. 둘 다 실행되지 않는다.

이 형태는 이 저장소에서 네 번째다 — 모듈 14 §44.2(WebPlatformStartupValidator), 모듈 17 §4.1(WebSocketPlatformStartupValidator), 모듈 19 §3.5(KafkaTransactionProfileValidator), 그리고 여기. 그리고 모듈 18에서 확립한 규칙이 다시 성립한다 — 시작 검증기가 도는지 여부는 그 능력에 자동설정 루트가 있는지와 일치한다. 여기서는 루트가 있는데도 검증기를 부르지 않는 첫 사례다.

채택 시점 실패 시나리오. 팀이 runtime_memberships에 런타임을 추가하고 ca-skeleton.grpc.platform.enabled=true로 켠다. Stable catalog에 client-streaming 메서드를 하나 등록한다(Stable 범위 밖이라는 것을 모른 채). 부팅은 성공한다. 그 메서드는 Stable이 보장하지 않는 경로로 실행되고, grpc-advanced-streaming의 세션·중복제거·체크포인트 기계는 조립돼 있지 않다. 거부했어야 할 검증기는 존재하고, 테스트도 12개 통과하며, 호출되지 않는다.

증거: 268-grpc-assembly-and-release-gate.txt

3.2 P2 — 릴리스 게이트가 스스로 증거를 읽지 않는다. messaging이 이미 고친 모양을 되풀이한다

docs/compatibility/grpc-support-matrix.md:

"GrpcCompatibilityMatrix.caSkeleton()은 이 표의 machine-readable form이고, GrpcStableReleaseGate가 certified lane에 결과가 없거나 실패하면 릴리스를 막는다."

게이트 자체의 설계는 훌륭하다 — missingResults + missingGrades + 스키마 판정 + 런북/ADR/지원매트릭스 존재를 합쳐 blocker 목록을 만들고, 문서 부재를 후속 과제가 아니라 차단 사유로 둔 근거까지 적는다:

"Documents are a blocker rather than a follow-up. ... shipping the behaviour and writing the runbook afterwards means the first person to meet it is the one who has to work it out at three in the morning."

그런데 게이트가 읽는 증거를 아무도 생산하지 않는다.

new GrpcReleaseEvidence(...) 생성 지점:
  grpc-testkit/src/test/.../GrpcStableReleaseGateTest.java:28, 84, 103, 127     <- 전부 테스트

GrpcStableReleaseGate / GrpcCompatibilityMatrix 참조 파일:
  grpc-testkit/src/test/.../GrpcStableReleaseGateTest.java
  grpc-testkit/src/main/.../GrpcStableReleaseGate.java
  grpc-testkit/src/main/.../GrpcCompatibilityMatrix.java

GrpcReleaseEvidence는 record이고 그 다섯 성분 — gradesRun, certifiedCapabilities, runbookPresent, architectureDecisionRecordsPresent, supportMatrixPresent — 이 전부 호출자가 넘기는 값이다. runbookPresent는 파일 시스템을 보지 않고, gradesRun은 레인 출력에서 파생되지 않는다. evaluate(...)에 넘기는 laneResultsMap<String, Boolean>으로 호출자가 만든다.

이것이 messaging 가족이 이미 고친 모양이다. 모듈 19 §6.6이 인용한 CompatibilityMatrix.Entry javadoc:

"Read from the evidence rather than declared. As a field it was a boolean an author set next to the tier, and RabbitMQ carried true while no fault scenario had ever been executed against it."

messaging은 그것을 세 층으로 닫았다 — (a) 레인이 broker-certification-evidence.jsonl쓰고, (b) verifyMessagingCertificationEvidence Gradle 태스크가 실행 산출물과 커밋본을 양방향 대조하며, (c) messaging-certification.ymlsrc/messaging/** PR마다 그 게이트를 돌린다.

gRPC 가족에는 (a)·(b)·(c) 어느 것도 없다:

src/grpc*/*/build.gradle 의 tasks.register  → 0건
.github/workflows 28개 중 grpc를 언급하는 것 → 0건

GrpcStableReleaseGate자기 단위 테스트가 유일한 실행 경로인 클래스다. 지원 매트릭스의 "릴리스를 막는다"는 현재 시제 문장이 그 상태를 서술하지 않는다.

이 가족은 messaging의 MSG-015를 반복하지 않는 것을 목표로 삼았고(§2.3에서 확인했듯 그 목표는 달성했다), 다른 교훈 하나를 옮겨 오지 않았다.

3.3 P2 — 증거 등급 모델 전체가 자동 실행 경로 밖에 있고, CLAUDE.md는 현재 시제로 서술한다

src/grpc/CLAUDE.md:

"현재 in-process·Netty·fault lane은 실제로 실행되어 통과하지만, 실제 배포 환경에서의 soak·performance baseline은 없다."

앞 절반은 사실이다. 직접 돌려 확인했다:

./gradlew :grpc:grpc-testkit:grpcInProcessContractTest \
          :grpc:grpc-testkit:grpcNettyContractTest \
          :grpc:grpc-testkit:grpcFaultTest
→ BUILD SUCCESSFUL, GRADLE_EXIT=0

grpcInProcessContractTest   classes=1 tests=7 failures=0 skipped=0
grpcNettyContractTest       classes=1 tests=9 failures=0 skipped=0
grpcFaultTest               classes=1 tests=9 failures=0 skipped=0

문제는 "실행되어"의 주어다. ca.strict-test-lane.gradle은 레인을 verification 그룹의 Test 태스크로 등록만 하고 check에 연결하지 않는다:

tasks.register(lane.name, Test) { group = 'verification'; ... }
(check dependsOn 관련 라인 → 0건)

그리고 CI에서 grpc를 이름으로 부르는 워크플로가 없다. ci-quality-gates.yml./gradlew check를 돌리므로 각 leaf의 **기본 test**는 CI에서 실행된다(classes=71 tests=579 failures=0 skipped=0으로 통과 확인). 그러나 네 증거 레인은 check에 없고 어떤 워크플로도 이름으로 부르지 않는다.

결과적으로 이 플랫폼의 CONTRACT/TRANSPORT/FAULT 등급을 뒷받침하는 것은 25개 테스트(7+9+9)이고, 그 25개는 누군가 명령을 직접 입력할 때만 돈다.

모듈 18 §4.1c가 ConditionalTransportCompositionContractTest의 javadoc에서 인용한 문장이 그대로 적용된다:

"A gate that is red in a lane nobody runs locally is a gate that reports whatever the last person to run it saw."

차이는 이쪽 레인이 오늘 초록이라는 것이고, 그것을 확인한 방법이 내가 직접 돌린 것이라는 점이다. 자동화된 관찰자는 없다.

(비교: messaging의 인증 레인도 test에서 제외되지만, 전용 CI 워크플로가 게이트를 돌리고 게이트가 레인에 의존한다. gRPC 쪽은 제외만 있고 대체 경로가 없다.)

3.4 P2 — 조립 경계가 정책 객체 9개를 만들고 서버를 만들지 않는다

GrpcPlatformAutoConfiguration이 등록하는 9개는 전부 프로파일·정책·레지스트리다. 서버도, 인터셉터 체인도, 서비스 어댑터 등록도 없다. 그리고 그것을 담당하는 타입들이 main 참조 0이다:

타입 leaf 역할 (javadoc) main 참조 test 참조
GrpcServerInterceptorChain server "Builds the server interceptor chain in the Stable order and hands it over in the order gRPC actually wants" 0 1
ProtovalidateGrpcInterceptor policy 요청 검증 인터셉터 0 1
GrpcIdempotencyInterceptor policy 멱등성 인터셉터 0 1
GrpcServiceAdapter server typed service adapter SPI 0 1
GrpcRetryCoordinator · GrpcRetryOwnershipValidator policy 재시도 소유권 0 1
GrpcStreamAdmission · GrpcSerializedStreamWriter · GrpcStreamGapDetector · GrpcStreamLifecycleCoordinator policy server streaming 단일 writer·갭 탐지 0 1
GrpcDrainCoordinator · GrpcPlatformSnapshotService admin drain·정책 스냅샷 0 1
GrpcTypedStubFactory · GrpcClientCallContext client typed stub·호출 컨텍스트 0 1

GrpcServerInterceptorChain의 javadoc이 자기 존재 이유를 이렇게 적는다:

"That reversal is the reason this class exists rather than a list literal at the call site. ServerInterceptors.intercept wraps each interceptor around the previous one, so the last one passed is the outermost at runtime — the opposite of how the order reads. Every codebase that builds this list by hand gets it backwards at least once, and the symptom is an exception boundary that catches nothing."

그 클래스를 조립에서 쓰는 곳이 없으므로, 채택자가 인터셉터 목록을 직접 만들면 그 javadoc이 서술한 실수를 그대로 하게 된다.

전체로 보면 260개 main 타입 중 73개가 main 참조 0이다. 다만 이 숫자는 그대로 결함 수가 아니다 — build-only 라이브러리 가족에서 공개 API 표면(채택자가 부르는 타입)이 내부 참조를 갖지 않는 것은 정상이다. 위 표는 그중 가족 내부의 다른 코드가 불러야 하는 조립·기계 타입만 골라낸 것이다.

3.5 P3 — 저장소 어디에도 참조가 없는 타입 3개

main = 0이면서 test = 0인 것, 즉 선언 파일 외에 아무 곳에서도 이름이 등장하지 않는 타입:

타입 leaf javadoc이 말하는 용도
ReactiveGrpcClient advanced-compat "Exposes a unary call as a Mono and a server stream as a Flux"
ReactiveGrpcServerAdapter<C,R> advanced-compat "Runs a reactive use case behind a gRPC service adapter"
GrpcDeadlineExceededException core-api "Its own type rather than a generic platform exception because callers branch on it"

앞의 둘은 advanced 가족의 Reactor 표면이고 채택자가 부를 타입이므로 참조 0이 설계와 모순되지는 않는다 — 다만 테스트도 0이라 다른 advanced 타입들과 다르다(나머지 advanced 미참조 타입은 전부 test=1).

세 번째가 더 구체적이다. GrpcDeadlineExceededException은 Stable core-api에 있고, javadoc이 "callers branch on it"이라고 단정하는데 던지는 코드도 잡는 코드도 테스트도 없다. requiresReconciliation()이 "status code가 답할 수 없는 질문에 답한다"고 적혀 있고, 그 메서드를 부르는 곳이 없다.

3.6 P3/기록 — 가족 문서의 grpc-discovery 행이 UDS를 빠뜨린다

src/grpc/CLAUDE.md의 family 표:

grpc-discovery | Static/DNS resolver, pick_first/round_robin, Kubernetes routing profile

코드와 지원 매트릭스는 셋을 말한다:

// GrpcResolverType
/** A Unix domain socket. One endpoint by construction. */
UNIX("unix", false);

// GrpcDiscoveryPolicyValidator:65
"'; Stable schemes are dns, static and unix"
docs/compatibility/grpc-support-matrix.md
| Stable resolvers | Static, DNS, Unix domain socket |

CLAUDE.md 쪽이 덜 완전하다. 드리프트 방향이 과소 진술이므로(있는 능력을 빠뜨림) 위험은 낮다 — 모듈 19 §6.3의 P1은 반대 방향(없는 능력을 있다고 적음)이었다. 기록으로 남긴다.


4. 네 가지 필수 negative-space 탐침

4.1 §8.1 도달성

§3.1·§3.4·§3.5가 결과다. 요약: main 참조 0인 260개 중 73개, 그중 가족 내부가 불러야 할 조립·검증 타입 15종이 §3.4 표와 §3.1이다. build-only 등급이 전부를 채택 시점 부채로 만든다.

4.2 §8.2 조건부 형제 비교
형제 쌍 차이 판정
GrpcPlatformAutoConfiguration(9 bean 등록) vs GrpcPlatformStartupValidator(호출 0) 같은 leaf, 같은 패키지, 4개 파일 중 하나만 조립에 연결 P2 §3.1
messaging 인증 게이트(레인→manifest→Gradle→CI 4층) vs grpc 릴리스 게이트(테스트 1층) 같은 설계, 강제 층이 다름 P2 §3.2
messaging messagingCertificationTest(전용 CI 워크플로) vs grpc 4개 레인(check 밖, CI 0건) 둘 다 test에서 제외, 대체 경로는 한쪽만 P2 §3.3
advanced 미참조 타입 대부분(test=1) vs Reactor 2종(test=0) 같은 leaf 안의 검증 비대칭 P3 §3.5
grpc-core-apidependencies{} vs messaging-core-api(deps 0이지만 build.gradle에 명시 없음) 둘 다 framework-free, grpc 쪽이 더 강한 형태 결함 아님 §2.1
4.3 §8.3 중복 장치 쓸기

이 가족에서는 중복 장치가 발견되지 않았다. 확인한 축:

  • 상태 코드 번역: GrpcStatusCode(core-api, 미러) ↔ GrpcStatusMapping(policy, 양방향 번역) — CLAUDE.md가 "양방향 번역은 grpc-policyGrpcStatusMapping단독으로 소유한다"고 선언하고, 실제로 core-api에는 번역 코드가 없다.
  • 시작 검증: Stable 쪽 GrpcPlatformStartupValidator 1개, advanced 쪽 GrpcAdvancedModuleGuard·GrpcXdsStartupGuard·GrpcServletStartupValidator가 각 capability를 나눠 담당 — 겹치지 않는다.
  • 증거 등급: GrpcEvidenceGrade 하나가 등급을 소유하고 GrpcReleaseEvidence.supports가 그것을 재사용한다 — 두 번째 등급 어휘가 없다.

모듈 19에서 4건(접근 검사·자격 증명 회전·Kafka producer·인증 증거 검증)이 나온 것과 대비된다.

4.4 §8.4 문서·카운트 드리프트
# 주장 실제 판정
1 지원 매트릭스: "Not released … build-only" 18 leaf 전부 rt=[]사실 결함 아님 §2.6
2 지원 매트릭스: 7개 레인·등급표 GrpcCompatibilityMatrix.caSkeleton()과 전수 일치 결함 아님 §2.6
3 지원 매트릭스: Spring Boot 4.0.8 / Stable resolvers Static·DNS·UDS src/build.gradle:13 = 4.0.8, GrpcResolverType에 UNIX 존재 — 일치 결함 아님
4 지원 매트릭스: "GrpcStableReleaseGate가 릴리스를 막는다" 게이트를 호출하는 build·CI 경로 0 P2 §3.2
5 CLAUDE.md: "GrpcPlatformStartupValidator가 startup을 거부한다" main 참조 0 P2 §3.1
6 CLAUDE.md: "in-process·Netty·fault lane은 실제로 실행되어 통과한다" 돌리면 통과(검증함). 자동으로 도는 경로는 없음 P2 §3.3
7 CLAUDE.md: grpc-discovery = Static/DNS 코드·지원매트릭스는 Static/DNS/UDS P3 §3.6
8 state.json 44 스코프 vs 레지스트리 62 모듈 리비전 이동(a24ece9c21234e38)이 원인. 이 문서로 해소 기록 §0

5. 발견 종합 — P1 0건 · P2 10건 · P3 3건

심각도 § 발견 위치
P2 3.1 GrpcPlatformStartupValidator가 유일한 조립 지점에서 호출되지 않는다 (main 참조 0) GrpcPlatformAutoConfiguration
P2 3.2 릴리스 게이트가 읽는 증거를 아무도 생산하지 않는다 — Gradle 태스크 0, CI 워크플로 0. messaging이 이미 닫은 모양의 재발 GrpcStableReleaseGate · GrpcReleaseEvidence
P2 3.3 증거 등급 모델(25개 테스트)이 check 밖·CI 밖이고, 문서는 현재 시제로 서술한다 ca.strict-test-lane.gradle · grpc-testkit/build.gradle
P2 3.4 조립 경계가 정책 객체 9개만 만들고, 인터셉터 체인·서비스 어댑터·스트리밍 기계가 전부 미조립 GrpcPlatformAutoConfiguration
P2 7.1 GrpcAdmissionController.tryAdmit() check-then-act — 조립되는 9개 bean 중 하나이고, 부하 아래에서 지키라고 만든 동시성 경계가 부하 아래에서 샌다. release()는 카운터를 음수로 만들 수 있다 grpc-server
P2 7.2 GrpcStreamAdmission도 같은 TOCTOU. 추가로 perCaller 맵이 caller fingerprint마다 자라고 제거되지 않는다 grpc-policy
P2 7.3 GrpcSerializedStreamWriterDROP_OLDEST버려지는 메시지가 아니라 들어오는 메시지의 바이트를 뺀다. 봉투가 크기를 담지 않아 알 방법이 없고, 테스트는 고정 크기 sizer라 결함이 보이지 않는다 grpc-policy
P2 7.4 GrpcCredentialRotationManager가 CAS 없이 read-then-write — 동시 회전 시 한 세대가 드레인 없이 사라진다. messaging이 CredentialRotationContractTest로 닫은 결함의 재현 grpc-policy
P2 7.5 GrpcOutcomeReplay가 제거·TTL·개수 상한이 하나도 없는 인메모리 저장소. 커밋한 멱등 연산마다 영구 적재 grpc-policy
P2 7.6 GrpcCompletionReconciler.pending이 요청 경로에서 동기화 없이 변경되는 ArrayList grpc-policy
P3 3.5 저장소 어디에도 참조가 없는 타입 3개 (ReactiveGrpcClient·ReactiveGrpcServerAdapter·GrpcDeadlineExceededException) advanced-compat · core-api
P3 3.6 가족 문서의 grpc-discovery 행이 UDS resolver를 빠뜨린다 src/grpc/CLAUDE.md
P3/기록 0 분석 기준선 이후 리비전이 이동해 18개 모듈이 state.json 밖에 있었다 state.json

P1이 0인 이유는 명확하다 — 18개 leaf 전부 runtime_memberships: []이고, 그 사실을 운영 문서가 정확히 공시한다(§2.6). 어떤 배포도 이 코드를 싣지 않으므로 "지금 틀린 동작"이 성립하지 않는다. 다만 §7.1의 GrpcAdmissionController는 조립되는 9개 bean 중 하나이므로, 채택하는 날 가장 먼저 청구되는 부채다.

5.1 검증된 설계 — 8건
  1. grpc-core-apidependencies {} — framework-free가 문서가 아니라 클래스패스로 강제됨 (§2.1)
  2. Stable → advanced 금지가 레지스트리·소스·빌드·런타임 네 층에서 일치 (§2.2)
  3. adapter-inbound-grpc가 이 가족을 볼 수 없다 — messaging MSG-015의 재발 방지가 실제로 성립 (§2.3)
  4. GrpcEvidenceGrade가 in-process 결과로 전송 능력을 주장하는 것을 런타임에 거부 (§2.4)
  5. 성능 레인을 기본 test에서 제외하고 그 이유를 적음 (§2.5)
  6. 지원 매트릭스가 "Not released / build-only"와 미해결 게이트 입력 2건을 스스로 공시 (§2.6)
  7. GrpcCompatibilityMatrix ↔ 문서 표 전수 일치 (§2.6)
  8. 중복 장치 0 — 상태 번역·시작 검증·증거 등급 모두 단일 소유자 (§4.3)
5.2 이 가족의 성격 — 계약은 강하고 조립은 아직 없다

이 가족은 messaging을 명시적으로 참조하며 만들어졌다. src/grpc/CLAUDE.md가 "messaging:*의 MSG-015(bridge 부재)를 반복하지 않는 것이 이 family의 목표"라고 적고, 실제로 그 목표는 달성했다 — adapter-inbound-grpc가 이 가족에 의존하지 않도록 레지스트리가 막고 있고, 그 사실을 문서가 정확히 말한다.

그런데 옮겨 오지 않은 교훈이 하나 있다. messaging이 값비싸게 배운 것은 "게이트는 자기가 검사할 증거를 스스로 읽어야 하고, 그 게이트를 CI가 돌려야 한다"였다(모듈 19 §2.3·§6.6). gRPC 가족은 그 게이트의 설계를 더 정교하게 만들었으면서(4등급 증거, 문서 부재를 blocker로) 강제 배선은 만들지 않았다 — Gradle 태스크 0, CI 워크플로 0, check 연결 0.

그래서 이 가족의 조립 층 P2 네 건은 전부 같은 축에 있다: 판정하는 코드는 잘 만들어졌고, 그것을 부르는 코드가 없다. §3.1(시작 검증기), §3.2(릴리스 게이트), §3.3(증거 레인), §3.4(인터셉터·어댑터)가 모두 그 형태다. build-only 등급이 오늘의 사고를 막고 있고, 채택하는 날 그 넷이 동시에 부채로 청구된다.

그리고 §7이 같은 판정을 구현 층에서 반복한다. 조립 층의 형태가 "부르는 코드가 없다"였다면 구현 층의 형태는 "원자적으로 하지 않는다"다 — AtomicInteger/AtomicReference를 쓰면서 compareAndSet을 쓰지 않는 것이 세 곳, 경계를 선언하고 유지 장치를 두지 않은 것이 두 곳이다. 그리고 두 층 모두, 정확한 참조 구현이 같은 가족 안에 이미 있다.


6. 검증

6.1 테스트 레인
./gradlew (18개 grpc leaf의 :test 전량) --console=plain
→ BUILD SUCCESSFUL in 1m 12s · 81 actionable tasks · GRADLE_EXIT=0
   XML 집계: classes=71 tests=579 failures=0 errors=0 skipped=0

./gradlew :grpc:grpc-testkit:grpcInProcessContractTest \
          :grpc:grpc-testkit:grpcNettyContractTest \
          :grpc:grpc-testkit:grpcFaultTest --console=plain
→ BUILD SUCCESSFUL · GRADLE_EXIT=0
   grpcInProcessContractTest  classes=1 tests=7 failures=0 skipped=0
   grpcNettyContractTest      classes=1 tests=9 failures=0 skipped=0
   grpcFaultTest              classes=1 tests=9 failures=0 skipped=0

Netty 레인이 실제 소켓을 열고 통과한다 — 컨테이너 안에서도 재현된다.

돌리지 않은 레인: grpcPerformanceTest. 공유 러너에서의 측정이 flaky 게이트가 된다는 이유로 test에서 제외돼 있고(§2.5), 분석 컨테이너의 측정값은 baseline이 될 수 없다.

6.2 소스 트리 변경 없음
git status --short → (출력 없음)
6.3 커버리지 원장

이 문서의 읽기 깊이는 앞선 모듈들보다 얕다. 그 사실을 숫자로 적는다.

FULL_READ는 파일 전문 또는 그에 준하게 읽은 것만 센다. 나머지는 전부 STRUCTURAL_ONLY이며, 그 근거는 (a) 파일·패키지 전수 목록, (b) 260개 main 타입 전수 도달성 스윕(선언 파일 제외 참조 수를 main/test로 분리 계수), (c) 레지스트리·build.gradle·.imports 전수 판독, (d) 18 leaf :test 전량 + 증거 레인 3종 실행이다. 즉 조립·경계·거버넌스 층은 전수로 확인했고, 각 leaf의 구현 내부는 읽지 않았다.

sub-scope leaf 파일 FULL_READ 전문으로 읽은 것
01 core contracts 3 68 3 grpc-core-api/build.gradle · GrpcStatusCode(javadoc+상수) · GrpcDeadlineExceededException(헤더)
02 policy 1 82 21 idempotency 4 · streaming 5 · resilience 3 · security 2 · deadline 1 · validation/policy/error/context 전수 스윕 + 후보 본문 확인 (§7)
03 server·client·discovery 3 54 5 GrpcServerInterceptorChain(헤더+javadoc) · GrpcResolverType/GrpcDiscoveryPolicyValidator(해당 행)
04 admin·observability·ledger·조립 4 41 6 GrpcPlatformAutoConfiguration(106줄 전문) · GrpcPlatformStartupValidator(188줄 전문) · .imports
05 testkit·증거·릴리스 1+1 37 5 GrpcEvidenceGrade · GrpcStableReleaseGate · GrpcReleaseEvidence · grpc-testkit/build.gradle · src/grpc/CLAUDE.md
06 advanced 6+1 101 5 src/grpc-advanced/CLAUDE.md · ReactiveGrpcClient(헤더) · ReactiveGrpcServerAdapter(헤더)
18 383 45

STRUCTURAL_ONLY 338 · EXCLUDED 0 · 미배정 0.

(2026-08-31 보강: 최초 기재는 FULL_READ 16이었다. §7의 구현 내부 판독으로 29개가 추가됐다 — grpc-policy 21, 나머지 leaf 8. leaf 귀속분 43 + 가족 거버넌스 문서 2 = 45.)

이 깊이로 확정할 수 있는 것과 없는 것:

  • 확정됨 — §2(공개 주장 6건), §3.1~§3.4(조립·릴리스 게이트·증거 레인·인터셉터 미조립), §3.5(참조 0 타입), §4.1~§4.4. 이 판정들은 전부 도달성·조립·빌드 구성에 대한 것이고 근거가 전수다.
  • §7로 확정됨grpc-policy의 동시성·경계 층. 6건이 나왔고 2건은 검증 중 철회했다(§7.7).
  • 여전히 확정되지 않음 — 읽기의 초점이 동시성과 경계였으므로, 각 leaf의 도메인 로직 정확성은 그 초점 밖이다. 구체적으로 grpc-proto-contract의 스키마 규칙 판정(3 main / 605 LOC), grpc-codegen의 매니페스트 해시 규약, grpc-advanced-resilience의 hedging 적격성·xDS 실패 정책, grpc-advanced-compat의 Servlet/gRPC-Web 프로파일 판정은 구조와 도달성만 확인했다.

비교를 위해: 모듈 19(messaging)는 550 파일에 1,284줄 문서, 모듈 14(web)는 638 파일에 1,702줄이었다. 이 문서는 383 파일에 (§7 보강 후) 약 700줄이다.

6.4 증거

evidence/raw/266268 (3개 신규). 264·265는 교차 스코프 준비 중 생성됐고, 264는 리비전 이동 이전 트리 상태에서 만들어져 .imports 7개·leaf 44개를 담고 있다 — 이동 이후 값은 .imports 8개·leaf 62개이며 265268이 현재 상태를 담는다.


7. 구현 내부 판독 (2026-08-31 보강)

§6.3이 미독으로 기록한 층 — grpc-policy 62 main / 4,781 LOC, grpc-server·grpc-client·grpc-admin의 상태 보유 클래스, grpc-advanced-streaming·-bootstrap — 을 읽었다. 동시성과 경계(bound)에 초점을 두었다. 앞선 모듈에서 상태·펜싱·순서 결함이 나온 층이 정확히 여기이기 때문이다.

방법: (a) 가족 전체 main 소스에서 mutable 컬렉션·카운터를 보유한 클래스를 전수 추출하고 각 파일의 동기화 마커 수를 병기, (b) get() 비교 후 increment/set을 수행하는 check-then-act 패턴 전수 추출, (c) 키가 늘기만 하고 제거가 없는 컬렉션 전수 추출. 그 뒤 각 후보를 본문으로 확인했다.

7.1 P2 — GrpcAdmissionController.tryAdmit()의 동시성 경계가 동시성 아래에서 성립하지 않는다

이 클래스는 조립된다GrpcPlatformAutoConfiguration의 9개 bean 중 하나(grpcAdmissionController)다.

public Decision tryAdmit() {
  int running = inFlight.get();
  if (running < maxConcurrentCalls) {
    inFlight.incrementAndGet();          // 검사와 증가 사이가 열려 있다
    return new Decision(true, ...);
  }
  int waiting = queued.get();
  if (waiting < maxQueuedCalls) {
    queued.incrementAndGet();
    return new Decision(true, ...);
  }
  ...
}

AtomicInteger를 쓰지만 원자적 연산은 하나도 하지 않는다. get()으로 읽고 비교한 뒤 별도로 incrementAndGet()한다. 경계에 있는 N개 스레드가 모두 같은 running을 읽고 모두 통과해 모두 증가시킨다 — inFlightmaxConcurrentCalls를 최대 N1만큼 초과한다.

클래스 javadoc이 존재 이유를 이렇게 적는다:

"Rejecting with RESOURCE_EXHAUSTED is a better outcome than queueing for two reasons that both matter under load... A server that queues instead spends its capacity finishing requests nobody is reading."

부하 아래에서 지키라고 만든 경계가 부하 아래에서 새는 구조다. 동시 요청이 없을 때는 정확하고, 있을 때 부정확하다.

해제 쪽도 같다:

public void release() {
  if (inFlight.get() > 0) { inFlight.decrementAndGet(); }
}

inFlight == 1일 때 두 스레드가 동시에 release()하면 둘 다 > 0을 통과해 둘 다 감소시켜 1이 된다. 그 뒤로는 running < maxConcurrentCalls가 한 칸 더 쉽게 통과하므로 경계가 영구적으로 느슨해진다.

promoteFromQueue()는 한 단계 더 나아간다 — queued를 줄이고 inFlight를 늘리면서 inFlight를 경계와 대조하지 않는다. 큐에서 승격되는 호출은 동시성 한도를 무조건 통과한다.

같은 가족이 올바른 형태를 이미 갖고 있다. GrpcRetryBudget은 정확한 CAS 루프다:

public boolean tryConsume() {
  while (true) {
    long observed = tokens.get();
    if (observed < tokensPerRetry) return false;
    if (tokens.compareAndSet(observed, observed - tokensPerRetry)) return true;
  }
}

grpc-policy의 예산은 CAS로 닫혀 있고 grpc-server·grpc-policy의 두 admission은 check-then-act다.

7.2 P2 — GrpcStreamAdmission도 같은 형태이고, per-caller 맵이 줄지 않는다
public boolean tryAdmit(String callerFingerprint) {
  AtomicInteger callerCount = perCaller.computeIfAbsent(callerFingerprint, key -> new AtomicInteger());
  if (callerCount.get() >= maxStreamsPerCaller) return false;
  if (openStreams.get() >= maxConcurrentStreams) return false;
  callerCount.incrementAndGet();
  openStreams.incrementAndGet();
  return true;
}

§7.1과 동일한 TOCTOU이고, 이쪽은 javadoc이 서술하는 실패 시나리오가 곧 고동시성 상황이다:

"Without a bound, a client that reconnects on every error opens streams faster than the old ones close."

재접속 폭풍은 정의상 동시 요청이 몰리는 상황이고, 그때 경계가 가장 많이 샌다.

release()get() > 0decrementAndGet()이라 음수로 갈 수 있다.

그리고 perCaller에서 엔트리가 제거되지 않는다. computeIfAbsent가 caller fingerprint마다 AtomicInteger를 하나 만들고, release()는 값을 줄일 뿐 키를 지우지 않는다. 서로 다른 caller 수만큼 맵이 자라고 줄지 않는다 — 이 가족이 GrpcMetricCardinalityPolicy로 지표 태그에 대해 명시적으로 막는 것과 같은 종류의 증가이며, 여기에는 그 가드가 없다.

7.3 P2 — GrpcSerializedStreamWriterDROP_OLDEST가 잘못된 메시지의 바이트를 뺀다
case DROP_OLDEST -> {
  GrpcStreamEnvelope<T> dropped = queue.pollFirst();
  if (dropped != null) {
    queuedBytes = Math.max(0L, queuedBytes - nextBytes);   // nextBytes = 들어오는 메시지 크기
    droppedMessages++;
  }
  enqueue(kind, payload, snapshotVersion, resumeToken, nextBytes);
  yield GrpcStreamWriteResult.DROPPED;
}

버려지는 것은 dropped인데 빼는 값은 **새로 들어오는 메시지의 크기 nextBytes**다. GrpcStreamEnvelope는 7개 성분(streamId·sequence·kind·snapshotVersion·resumeToken·terminationReason·payload) 중 크기를 담지 않으므로, 이 지점에서 버려지는 메시지의 크기를 알 방법이 애초에 없다.

queuedBytes는 장식이 아니라 판정 입력이다:

// GrpcFlowControlPolicy.decide
boolean overflowsBytes = queuedBytes + nextMessageBytes > maxQueuedBytes;

그리고 그 바이트 경계의 존재 이유가 javadoc에 있다:

"Both a message count and a byte count, because either alone is unbounded in the other dimension: a thousand-message bound with no byte bound is a memory limit set by the largest message anyone ever sends."

실패 시나리오. DROP_OLDEST 프로파일 + 가변 크기 메시지. 10바이트 메시지를 버리며 10,000바이트를 넣으면 queuedBytes는 10,000을 빼고 10,000을 더해 변화 없음인데 실제 큐는 9,990바이트 늘었다. 반복되면 queuedBytes가 실제보다 계속 낮아지고(Math.max(0, ...)로 0에서 멈춘다) 바이트 경계가 발화하지 않게 되어, 큐는 메시지 수 경계까지 임의 크기 메시지로 채워진다 — 바이트 경계가 막으려던 바로 그 상태다. 반대 방향(큰 것을 버리고 작은 것을 넣음)에서는 과대 계상돼 조기 TERMINATE가 된다.

테스트가 이 결함을 볼 수 없는 구성으로 되어 있다. GrpcSerializedStreamWriterTest의 lossy 케이스는

writer(new GrpcFlowControlPolicy(1, 1024L, 1, GrpcSlowConsumerPolicy.DROP_OLDEST), 8L)

— sizer가 상수 8L이라 모든 메시지 크기가 같고, maxQueuedMessages=1이라 발화하는 것은 개수 경계다. 크기가 같으면 잘못된 뺄셈이 우연히 옳은 값이 된다.

(Stable 기본값 GrpcFlowControlPolicy.stable()TERMINATE이므로 기본 경로는 영향을 받지 않는다. DROP_OLDEST는 opt-in 손실 허용 프로파일이다.)

7.4 P2 — GrpcCredentialRotationManager가 CAS 없이 read-then-write 한다. messaging이 고친 결함의 재현이다
public RotationPlan rotate(GrpcCredentialGeneration next, Instant now) {
  State observed = state.get();                                  // :89
  ...
  state.set(new State(next, observed.current(), deadline));       // :103
}
public void completeDrain() {
  State observed = state.get();                                  // :119
  state.set(new State(observed.current(), null, null));           // :120
}

AtomicReference를 쓰지만 compareAndSet·updateAndGet한 번도 없고 synchronized도 없다. 순수한 홀더로만 쓰인다.

두 회전이 동시에 일어나면 둘 다 같은 observed를 읽고 둘 다 supersededBy를 통과해 둘 다 set한다. 나중 것이 앞선 것을 덮으므로 한 세대가 draining에 오르지 못한 채 사라진다 — 그 세대 위의 in-flight 호출은 추적되지도, 드레인되지도 않는다. completeDrain()rotate()가 겹치면 새로 draining이 된 세대가 즉시 잊힌다.

이 클래스의 javadoc이 그 경합을 이미 알고 있다:

"@throws IllegalArgumentException when next does not supersede the current generation — a rotation that goes backwards would reactivate material that was already replaced, and the usual reason for one is two rotators racing"

경합의 존재를 적어 두고, 그 경합을 닫는 연산은 쓰지 않았다.

그리고 이것은 messaging이 이미 고친 결함이다. 모듈 19 §5.4가 인용한 CredentialRotationContractTest:

"resolve was get → fetch → put → clear with no synchronization. Two callers rotating the same credential both read the same old runtime and both fetched a replacement: one replacement was dropped from the map without ever being cleared — a secret left in memory that nothing owns — and the loser could clear material the winner was still using."

같은 주제(자격 증명 회전), 같은 결함 형태(공유 상태에 대한 read-then-write), 한 가족은 동시성 계약 테스트까지 만들어 닫았고 다른 가족이 재현했다. grpc-policy의 테스트 16개 중 동시성을 다루는 것은 없다.

7.5 P2 — GrpcOutcomeReplay가 제거 경로 없는 인메모리 저장소다
private final ConcurrentMap<String, byte[]> storedOutcomes = new ConcurrentHashMap<>();
  • maxInlineBytes엔트리 하나의 크기를 제한한다. 엔트리 개수를 제한하는 것은 없다.
  • remove·clear·evict·TTL이 하나도 없다(전수 grep 0건). size()만 있고 그 값을 읽는 곳도 없다.
  • store()IDEMPOTENCY_KEY_REQUIRED 메서드가 커밋될 때마다 호출되므로, 프로세스 수명 동안 커밋한 멱등 연산 수만큼 엔트리가 쌓인다.

javadoc은 "a small inline store"라고 부르지만 작게 유지하는 장치가 없고, 크기를 넘는 응답은 거부하면서("store it behind an object reference instead") 개수는 거부하지 않는다.

비교 대상이 같은 leaf 안에 있다. GrpcClientMessageDeduplicator는 정확히 이 문제를 피하려고 설계됐고 그 이유를 적는다 — "A set grows without bound for the life of a session... a monotonic applied-sequence answers it in constant space" — 그리고 endSession()으로 두 맵을 모두 정리한다. 같은 가족에서 한쪽은 정리하고 한쪽은 하지 않는다.

7.6 P2 — GrpcCompletionReconciler가 요청 경로에서 동기화 없는 ArrayList를 변경한다
private final List<PendingCase> pending = new ArrayList<>();          // :25
...
pending.add(new PendingCase(...));      // reconcile(...) 안 — 요청 경로
List.copyOf(pending);                   // pendingCases()
pending.remove(resolved);               // clearPending(...)

synchronized·Concurrent*·volatile·Lock 전부 0건이고, 단일 스레드 전용이라는 javadoc 표기도 없다. 이 leaf에서 스레드 안전성을 명시적으로 다루는 유일한 클래스는 GrpcSerializedStreamWriter이며(그쪽은 9개 마커로 제대로 닫혀 있다), 그 사실이 이 leaf가 동시성을 인지하고 있음을 보여준다.

reconcile(...)은 완료 결과가 불확실한 호출마다 불린다 — 장애 상황에서 동시에 몰리는 경로다. ArrayList에 대한 동시 add는 원소 유실 또는 ArrayIndexOutOfBoundsException이고, add 중의 List.copyOfConcurrentModificationException 또는 null 원소로 인한 NPE다. 그리고 pending이 담는 것은 결과를 알 수 없어 사람이 조정해야 하는 연산 목록이므로, 유실은 조정되지 않은 채 잊히는 연산이 된다.

7.7 검증 중 철회한 판정 2건

읽기 전 후보로 잡았다가 본문 확인 후 취소한 것들이다. 기록해 둔다.

후보 왜 취소했나
GrpcChannelRuntime.draining이 비-volatile이라 드레인 신호가 요청 스레드에 안 보일 수 있다 private volatile boolean draining — 이미 volatile이다. 남는 것은 release 계열의 check-then-act뿐이고, 그 경우 카운터가 음수가 되면 quiescent()가 영원히 false가 되어 드레인이 끝나지 않는 쪽으로 실패한다(조기 완료가 아니라). 호출자의 이중 해제를 전제하므로 별도 결함으로 세지 않는다
GrpcClientMessageDeduplicator의 두 ConcurrentMap이 무한 증가한다 endSession(sessionId)checkpoints.remove(...)replayableOutcomes.keySet().removeIf(...)로 둘 다 정리한다. 자동 스윕에서 제외로 잡힌 것이 맞았고 내가 과독했다
7.8 확인된 올바른 설계 (구현 층)
  1. GrpcResumeTokenCodec — 상수 시간 비교(MessageDigest.isEqual), 알 수 없는 key id를 현재 키로 폴백하지 않고 거부("turns key rotation into a window in which a token signed by a compromised key still verifies"), malformed·unknown key·verify 실패를 구별 불가능하게 반환("telling them apart is a probing oracle"). 그리고 GrpcResumeToken.requireBounded가 모든 문자열 필드에서 구분자 |를 명시적으로 거부해 인코딩/디코딩 비대칭이 생기지 않는다.
  2. GrpcCancellationCoordinator — 5개 메서드 전부 synchronized. 등록이 취소 이후 실패하는 것을 "no new external side effect after cancel"의 구현으로 삼고, markCommitBoundaryCrossed()로 커밋 이후 취소를 abort로 오해하지 않게 분리한다.
  3. GrpcRetryBudget — 정확한 CAS 루프. 성공이 토큰을 상한까지 회복시켜 "실패가 전면화되면 재시도가 사실상 0으로 수렴"하는 성질을 만든다.
  4. GrpcRetryCoordinator — 검사 순서가 고정(자격 → 설정 → status → 시도 수 → 남은 데드라인 → 백오프 후 잔여 → 예산)이고, 예산을 마지막에 소모한다. 어차피 거부할 재시도에 예산을 쓰지 않는다.
  5. GrpcRetryOwnershipValidator — 서비스 설정의 메서드 이름을 카탈로그와 대조한다. "rename CreateDocument to CreateDocumentV2 and the entry stops matching, silently."
  6. GrpcMetricCardinalityPolicy.retryBucket(attempts) — 시도 횟수를 버킷으로 접어 태그 카디널리티를 제한한다.
7.9 이 층의 성격

여섯 건 중 넷이 같은 형태다 — AtomicInteger/AtomicReference를 쓰면서 원자적 연산을 하지 않는 것(§7.1·§7.2·§7.4)과, 경계를 선언하고 그 경계를 유지하는 장치를 두지 않는 것(§7.2의 per-caller 맵·§7.5). 그리고 같은 가족 안에 정확한 참조 구현이 이미 있다 — 예산은 CAS 루프로, 스트림 라이터는 synchronized로, 중복제거기는 endSession으로 닫혀 있다.

§5.2가 조립 층에 대해 내린 판정이 구현 층에도 그대로 적용된다: 판정하는 코드는 잘 만들어졌고, 그것을 정확히 실행하는 부분이 빠져 있다. 조립 층에서는 "부르는 코드가 없다"였고, 구현 층에서는 "원자적으로 하지 않는다"다.

여전히 P1이 없는 이유는 §5와 같다 — 18 leaf 전부 runtime_memberships: []이고 어떤 배포도 이 코드를 싣지 않는다. 다만 §7.1의 GrpcAdmissionController조립되는 9개 bean 중 하나이므로, 채택 시점에 가장 먼저 청구되는 부채다.


A99. cross-scope

분석 중에는 99-cross-scope.md 파일이었다. 552줄.

99 · 교차 스코프 분석 — 사이클 2

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분모: 등록 리프 62 (COMPLETE 61 · EXCLUDED 1 sample-portfolio) 근거: 각 리프 SSOT 문서 61건 · evidence/raw/ 370건 사이클 1의 같은 경로에 초안이 있었고(364줄, 상태 NOT_STARTED), 이 문서가 그것을 대체한다. 초안은 보존하지 않았다 — 덮어쓰기 전에 읽지 않은 것은 이 사이클의 절차 실수다. 따라서 사이클 1의 절 번호와 줄 번호는 이 문서에 적용되지 않는다. root-tree.md가 옛 번호로 걸어 둔 앵커는 이 문서의 절로 다시 걸었다(§3.1 · §3.4 · §5 · §6 · §2·§4). 옛 초안에만 있던 서술이 있었다면 그것은 복구되지 않았다. 다만 그 초안이 인용하던 1차 근거는 전부 리프 SSOT와 final/document.md에 있으며, 이 문서는 그것들을 다시 읽고 작성했다. 사이클 2 후반 갱신. 이 문서의 §0~§6 은 18개 리프 재검증까지를 반영해 쓰였다. 그 뒤 같은 사이클에서 23개 리프(messaging 5 · grpc 18)의 production 구현을 통독했고, 그 결과를 §1.2 · §3.7 · §4 의 6~9항 · §6 에 더했다.

그 통독 자체를 다시 했다 — 이 문서의 §0 · §1.2 · §3.5 · §6 숫자가 그래서 바뀌었다. 앞선 판에서 23개 리프를 FULL_READ_DONE 으로 표시하고 "395파일 전수 통독" 이라고 적었으나, 실제로 읽은 것은 리프마다 일부였다. 그 표시가 사실이 아니었으므로 23개 리프를 파일 단위로 다시 세고 처음부터 다시 읽었다. 정직한 분모는 main 357파일 / 29,542줄 · test 121파일 / 19,756줄이고, 각 리프 SSOT 의 Coverage ledger 를 그 숫자로 다시 썼다. §1.2 의 결론 하나가 그 과정에서 뒤집혔다 — "통독이 만든 새 finding 에 P1 은 없다" 는 서술이 더 이상 참이 아니다.

지위: 이 문서는 2차 증거다. 리프의 사실은 리프 SSOT가 소유하고, 여기서는 리프 경계를 넘을 때만 성립하는 것을 다룬다.


0. 이 문서가 서 있는 분모

항목
등록 리프 62 (COMPLETE 61 · EXCLUDED 1)
analysisFile을 공유하는 리프 0 — SSOT 게이트 통과
리프 SSOT가 확정한 finding 462 (P1 30 · P2 147 · P3 285) — 아래 주 참조
finding 0건인 리프 8
evidence 파일 370
끊긴 evidence 참조 0
사이클 2 전수 통독 리프 23 (messaging 5 · grpc 18)
그 23개 리프의 main production 357파일 / 29,542줄
그 23개 문서의 finding 100 (P1 2 · P2 31 · P3 67)
그중 사이클 1 가족 문서에서 옮겨온 것 8
통독에서 처음 나온 것 92 (P1 2 · P2 25 · P3 65)

두 숫자의 근거가 다르다.

23개 리프의 100건은 이번에 직접 센 것이다 — 23개 문서의 §17 에서 ### 17.n P<k> — 형태를 파싱했고, 23개 문서 전부를 처음부터 끝까지 읽은 뒤이므로 표기 누락이 없다. 앞선 판의 60건은 통독이 실제로는 부분 통독이던 시점의 수치다.

전체 462건은 직접 다시 센 것이 아니라 델타로 조정한 값이다. 앞선 판의 422건에서 23개 리프 몫 60을 빼고 100을 더했다(422 60 + 100 = 462). 나머지 38개 문서는 이번 재작업의 대상이 아니었고, 그 문서들이 쓰는 「모듈 findings 표」 형식은 §17 형식과 파싱 규칙이 달라 두 형식을 함께 세는 스크립트를 이번에 다시 돌리지 않았다. 그러므로 462는 델타가 정확하다는 가정 위에 있고, 422 자체의 재측정은 아니다. 직접 재측정이 필요하면 두 형식을 모두 파싱하는 원래 스크립트를 61개 문서에 다시 돌려야 한다.

P1·P2·P3 내역도 같은 방식으로 조정했다 — P1 291+2=30, P2 13519+31=147, P3 25840+67=285.


1. 사이클 2가 실제로 바꾼 것

사이클 2의 재검증 대상은 사이클 1이 남긴 18개 리프 문서였다. 결과는 다음과 같다.

소스는 움직이지 않았다. 18개 문서가 모두 기준으로 삼은 a24ece9c와 현재 HEAD 21234e38 사이는 커밋 하나이고, 그 커밋은 src/grpc/**·src/grpc-advanced/**와 공통 파일 둘만 건드렸다. 18개 리프 경로의 변경 파일 수는 전부 0이다. 공통 파일 둘도 이 18개에 영향이 없다 — src/build.gradle의 변경은 plain-JUnit 테스트 클래스패스 조건에 :grpc:·:grpc-advanced:를 더한 것뿐이고, modules.json은 197줄 순수 추가로 18개 리프 id가 diff에 한 번도 등장하지 않는다 (EVD-333).

그래서 재검증의 실질은 재작성이 아니라 재확인이었다. 18개 문서의 lane을 HEAD에서 다시 돌렸고 (EVD-334), 실패 5건이 나왔다. 그 5건에 대한 판정은 이렇다.

리프 실패 사이클 1의 판정 사이클 2의 재측정
adapter-outbound-fileserver 1 환경(로케일) 확인LANG=C.utf8로 통과, sun.jnu.encoding ANSI→UTF-8
app-bootstrap 1 환경(jq 부재) + 가드 비대칭 P3 확인, 그리고 남은 공백을 메움 — 15개 레인 계약을 독립 경로로 검증
adapter-outbound-httpclient 3 P1 제품 결함 철회 — 픽스처의 듀얼스택 호스트명이 원인

세 번째가 이 사이클의 유일한 판정 번복이다. 사이클 1은 ApacheFailureClassifier의 분기 순서를 읽고 "Apache가 TLS 실패를 HttpHostConnectException으로 감싸므로 CONNECT 분기가 TLS 분기를 가린다"고 결론했다. 예외 사슬을 실제로 출력해 보면 그 사슬에 SSLHandshakeException없다. 원인은 MockHttpServer.uri()가 호스트명 localhost를 돌려주는데 이 컨테이너의 localhost127.0.0.1::1 양쪽으로 풀리고 MockWebServer는 IPv4에만 바인딩한다는 것이었다. Apache의 다중 주소 루프가 첫 주소(127.0.0.1)의 진짜 TLS 실패를 삼키고 마지막 주소(::1)의 연결 거부만 승격시킨다. 접속 호스트를 127.0.0.1로 바꾸면 세 건 모두 TLS_PERMANENT가 된다 (EVD-332).

17/18은 확인, 1/18은 번복. 이 비율 자체가 사이클 1 문서의 신뢰도에 대한 측정치다.


1.2 그 뒤에 이어진 전수 통독 — 23개 리프

18개 재검증과 별개로, 사이클 2 는 ssotReview: FULL_READ_REQUIRED 로 열려 있던 23개 리프를 닫았다. 그 리프들의 사이클 1 SSOT 는 production 구현을 STRUCTURAL_ONLY 로 판정하고 파일 이름·LOC·build.gradle 주석으로 서술한 상태였다.

통독은 두 번에 걸쳐 이뤄졌고, 첫 번째는 통독이 아니었다. 첫 판에서 23개 리프를 FULL_READ_DONE 으로 표시하고 각 SSOT 에 "production N파일 축자 통독 완료" 를 적었으나, 리프마다 읽은 것은 일부였다. 그 상태로는 Coverage ledger 가 사실이 아니므로 23개 리프를 다시 세고 파일 단위로 다시 읽었다.

대상 리프 main 파일 main 줄 test 파일 test 줄
grpc 계열 18 260 18,726 55 9,383
messaging 계열 5 97 10,816 66 10,373
합계 23 357 29,542 121 19,756

각 SSOT 가 주장을 거는 test 파일도 전부 읽었다. 통독 후 STRUCTURAL_ONLY 잔여는 0 이고, 23개 SSOT 의 Coverage ledger 는 위 숫자로 다시 썼다.

결과의 성격. 통독이 만든 것은 대부분 새로운 사고가 아니라 이미 알려진 패턴의 정확한 위치다. 사이클 1 의 가족 문서(19·20)는 두 가족을 각각 하나의 문서로 다루면서 "블록 전체가 미배선" 이라는 층위에서 멈췄고, 통독은 그 블록 안에서 배선되더라도 성립하지 않을 것들을 찾았다.

다만 P1 이 둘 나왔다. 앞선 판은 "새 finding 에 P1 은 0" 이라고 적었고 그것은 부분 통독의 결과였다. 둘 다 messaging 계열이고, 둘 다 "선언과 실제가 반대인데 관측은 정상" 이라는 §3.2 의 형태다.

  1. 운영 프로파일에 TLS 와 브로커 인증을 요구해 놓고, 그 둘이 없는 생산자를 만든다. KafkaProfileValidator 는 운영 프로파일이 전송 보안 없이 뜨는 것을 거부하고 그 거부를 테스트가 지킨다 (aProductionKafkaBrokerWithoutTransportSecurityFailsStartup). 그런데 실제로 조립되는 KafkaProducer 설정에는 security.protocol 이 없다 — Kafka 기본값 PLAINTEXT 다. 그 값을 만드는 KafkaSecurityConfigurer 는 저장소 전역에서 production 호출자가 0 이다. (messaging-spring-boot-starter §17.1)
  2. 지원 문서가 deduplicatedPublish 를 지원으로 적고 코드는 거짓이며, 그 차이가 정확히 코드가 경고한 피해다. (messaging-kafka §17.1)

이 둘이 P1 인 이유는 배선 여부와 무관하게 성립하기 때문이다. 나머지 23개 리프의 finding 대부분은 "조립되면 성립하는 결함" 이지만, 이 둘은 messaging 계열이 실제로 배선되는 경로 위에 있다.

그 아래 층위에서 가장 무거운 예 셋:

  1. JpaGrpcOperationLedger.claim 의 insert-first 주장이 Spring Data 의 save 계약과 어긋난다. 엔티티의 식별자가 배정값이라 savemerge 로 가고, 파생 기본 키가 유니크 제약과 같은 행을 가리키므로 두 번째 청구가 유니크 위반을 일으키지 않고 커밋된 결과를 덮어쓴다. 테스트 이중의 save 는 INSERT 를 흉내 내 그 차이를 가린다. (grpc-operation-ledger-jpa §17.1)
  2. GrpcCredentialRotationManager.completeDrain() 이 진행 중인 회전을 되돌린다. 읽기와 쓰기 사이에 회전이 일어나면 방금 교체된 자격증명이 되살아난다. (grpc-policy §17.2)
  3. GrpcPlatformStartupValidator 가 시작 시 실행되지 않는다. 그 검증기가 유일한 소비자인 설정 키 넷 (transport·tls-enabled·trust-all-certificates·operation-ledger-enabled)이 아무것도 게이트하지 않는다. (grpc-spring-boot-starter §17.1)

셋 다 "조립되면 성립하는 결함" 이고, 블록 수준 서술로는 보이지 않는 층위다.


2. 배포 지도 — 등록된 것과 배포되는 것의 거리

이 저장소에는 런타임 컴포지션이 둘 있다(runtime_compositions: app-bootstrap, sample-portfolio). 62개 리프의 소속을 그대로 세면 다음과 같다.

소속 리프 수
app-bootstrap에 속함 33
sample-portfolio에만 속함 2 (adapter-outbound-objectstorage, sample-portfolio)
어느 컴포지션에도 속하지 않음 27

속하지 않는 27개의 내역:

  • grpc 블록 18개 전부grpc-* 9개와 grpc-advanced-* 9개. 이는 결함이 아니라 명시된 상태다. src/grpc/CLAUDE.md:74-98이 블록의 build-only 상태를 정확히 적고 있고, 사이클 2의 측정은 그 서술의 확인이다 (EVD-325).
  • messaging 7개schema-avro, schema-protobuf, kafka-share-experimental, pulsar-experimental, nats-experimental, spring-cloud-stream-bridge, testkit. 실험 어댑터와 선택적 스키마·테스트 지원이라는 성격상 예상되는 목록이다.
  • adapter-inbound-grpcadapter-inbound-websocket — 이 둘은 위 두 범주 어디에도 속하지 않는 일반 인바운드 어댑터인데 어떤 배포에도 들어가지 않는다.

여기서 리프 경계를 넘어야만 보이는 사실이 하나 있다. adapter-inbound-websocket 문서는 이 리프의 내부 구조를 충실히 기술하지만, "이 리프가 어떤 배포에도 없다"는 것은 리프 안에서는 보이지 않는다 — modules.json의 다른 항목과 대조해야 나온다. 같은 형태로 adapter-outbound-objectstorage샘플에만 있다. 출하 애플리케이션에는 오브젝트 스토리지 어댑터가 없다.


3. 저장소 전체를 관통하는 패턴

422건을 finding 제목 텍스트에 대해 기계 분류했다. 제목만 읽는 분류이므로 아래 수치는 하한이고 census가 아니다 — 268건은 제목이 너무 짧아 어느 유형에도 걸리지 않았다.

유형 건수 P1 P2 P3 나타난 리프
A. 만들어졌지만 조립되지 않음 57 8 27 22 23
F. 문서·주석·이름이 코드와 다름 57 1 20 36 18
E. 동시성·경합·순서 26 5 8 13 12
B. 선언은 통과하는데 강제하는 주체가 없음 14 4 6 4 9
D. 같은 문제에 메커니즘이 둘 이상 12 0 2 10 9
C. 테스트가 픽스처를 검증함 / 검증 공백 11 4 2 5 6
3.1 A — 만들어졌지만 조립되지 않는다 (23개 리프)

이 저장소에서 압도적으로 반복되는 형태다. 세 층위로 나타난다.

층위 1 — 컴포지션 루트가 패키지를 제외한다. adapter-inbound-web의 P1 여섯 건은 전부 하나의 원인으로 수렴한다. 컴포지션 루트가 mvc.error·webflux.error·mvc.budget·mvc.operation· webflux.operation 다섯 패키지를 컴포넌트 스캔에서 제외하고, 그 결과 RFC 9457 계약 23개 파일, 용량 보호 계층 41개, 멱등 실행 계층 38개가 출하 애플리케이션에 등록되지 않는다. 그 문서 자신이 여섯 번째 finding에서 단일 원인을 지목한다 — "다섯 레인·세 런타임 패리티가 검증하는 것은 픽스처의 조립이고, '플랫폼이 능력을 설치하는가'를 묻는 레인이 없다."

층위 2 — 클래스는 있는데 생성자가 없다. messaging-inbox-jdbc-postgresql의 bounded purge는 구현돼 있고 호출되지 않는다. messaging-outbox-jdbc-postgresql은 그 반대 방향으로 같은 형태다 — 무제한 DELETE가 호출되고, 그것을 막는 bounded 오버로드가 호출되지 않는다. messaging-spring-boot-starter의 종료 수명주기는 아무도 증가시키지 않는 카운터가 0이 되기를 기다린다. adapter-outbound-httpclientPOOL_ROUTE_EXCEEDS_TOTAL 위반 코드는 생성자에 가려 발화할 수 없다.

층위 3 — 블록 전체. grpc 18개 리프. 이 경우만은 저장소가 그 상태를 문서로 인정하고 있다.

이 세 층위의 차이가 중요하다. 층위 3은 선언된 미완성이고, 층위 1·2는 선언되지 않은 미완성이다. 같은 저장소가 전자를 다루는 좋은 선례를 갖고 있다 — MessagingProviderSelection.BROKERS_WITHOUT_A_TRANSPORT는 rabbit의 미완성에 이름을 붙이고 기동에서 거절하며 "An entry leaves this map on the day its transport does exist"라고 적는다. 층위 1·2에는 그런 이름이 없다.

3.2 B — 검증기는 통과시키고, 그 값을 읽는 코드는 없다 (9개 리프)

A의 특수형이지만 결과가 다르다. A는 기능이 없는 것이고, B는 없는 기능이 있다고 보고되는 것이다.

가장 선명한 사례는 adapter-outbound-persistence-mongo다. MongoClientSettingsFactory가 저장소 전체에서 호출되지 않아 프로파일의 tlsRequired·타임아웃·풀 상한·Stable API·UUID 표현이 드라이버에 도달하지 않는다. 검증기는 "TLS 필수" 선언을 통과시키고, 연결은 평문일 수 있다. 선언과 실제가 반대인데 관측은 정상이다.

같은 형태가 messaging-pulsar-experimentalclaimCheckThresholdBytes에 있다 — 필드는 있고 그것을 읽는 액터가 없다. adapter-outbound-persistence-jpa의 바이트 쿼터도 같다 — persistent reserved + committed 값을 실제 admission에서 읽거나 상한과 비교하는 경로가 없다.

사이클 2 가 더한 것 — 능력 선언이 프로파일에서 파생되지 않는다 (3개 어댑터).

세 실험/출하 브로커 어댑터가 모두 MessagingCapabilities상수 로 둔다. 그리고 그 상수가 답하는 것과 그 능력이 실제로 성립하는 조건이 갈린다.

어댑터 상수가 답하는 것 실제 조건
messaging-nats-experimental deduplicatedPublish = true 프로파일에 중복 제거 창이 있을 때만 Nats-Msg-Id 를 보낸다
messaging-kafka brokerTransaction = true 트랜잭션 식별자 접두·멱등 생산자·acks=all·수동 커밋이 모두 필요하고, 그것을 검사하는 검증기는 주입되지 않는다
messaging-pulsar-experimental 전송과 검증기가 orderedStream서로 다른 Key_Shared 의 순서 단위는 키다

첫째가 특히 무겁다. deduplicatedPublish 는 능력 열둘 중 부재가 예외를 만드는 유일한 플래그이므로 (DefaultMessagePublisher:250), 창 없는 목적지가 그 가드를 통과한다.

이 셋은 B 의 거울상이다. B 는 선언을 통과시키고 읽는 코드가 없는 것이고, 이것은 읽히는 값이 조건과 무관하게 참 인 것이다.

3.3 C — 레인이 검증하는 것이 픽스처의 조립일 때 (6개 리프)

adapter-inbound-web의 다섯 레인, grpc 경계 규칙이 실제 소스를 보지 않는 것(EVD-328), messaging-testkit의 인증 매니페스트 드리프트(EVD-300)가 같은 계열이다.

여기에 사이클 2가 사례 하나를 보탠다 — app-bootstrapeveryLaneMatchesItsContractdocker compose 부재는 skip으로 막고 jq 부재는 실패로 낸다. 스크립트는 전제 결손을 exit 78 (sysexits.h의 EX_CONFIG)로, 계약 위반을 exit 1구분해서 알리는데 테스트가 그 구분을 버린다. 결과적으로 아무것도 검사되지 않은 상태가 "레인이 계약과 다르다"로 보고된다 (EVD-334).

3.4 D — 같은 문제에 메커니즘이 둘 (9개 리프)

messaging-admin-runtime의 두 토폴로지 스택(EVD-307), messaging-kafka의 세 층위 재시도 중 하나만 도는 것, grpc의 두 규칙 엔진 중 하나만 급여되는 것(EVD-329), adapter-outbound-httpclient의 재생 가능성 판정이 호출마다 반사로 재계산되는 것.

이 유형이 P3에 몰려 있는 것(12건 중 10건)은 우연이 아니다. 둘 중 하나는 대개 돌고 있어서 증상이 없다. 비용은 다음에 고치는 사람이 어느 쪽이 정본인지 모른다는 데서 나온다.

3.5 E — 동시성·경합 (12개 리프)

P1 다섯 건이 여기 있다. adapter-outbound-persistence-jpa의 outbox stale worker가 owner fencing 없이 newer/terminal 상태를 덮어쓰는 것, provider call recorder가 lease-unaware save()를 써서 만료된 holder의 stale projection이 새 holder를 덮는 것, adapter-outbound-persistence-mongo의 change stream high-water mark가 "본 위치"여서 failover 중 이벤트가 조용히 영구 소실되는 것, messaging-admin-runtime의 재개된 리드라이브가 옮기지 못한 메시지를 영구히 건너뛰는 것.

이 넷의 공통 형태는 실패가 상태로 남지 않는다는 것이다. 넷 다 로그도 상태 전이도 남기지 않고, RUNNING 또는 성공으로 보이는 채로 데이터가 사라지거나 덮인다.

사이클 2 가 더한 것 — 원자 타입을 쓰면서 비교 후 교체를 하지 않는다 (5곳).

전수 통독이 찾은 가장 일관된 형태다. 다섯 곳이 AtomicReference·AtomicInteger 를 선택해 놓고 읽고-판단하고-쓰는 세 단계를 원자적으로 묶지 않는다.

위치 형태 결과
GrpcCredentialRotationManager.rotate·completeDrain get() 후 조건 없는 set() 회전이 되돌아가 교체된 자격증명이 되살아난다
GrpcChannelRuntimeRegistry.rotate 〃 (같은 파일의 install 은 CAS 를 쓴다) 덮인 대체본이 배수도 회수도 되지 않는다
GrpcChannelRuntime.finishUnaryCall·closeStream get() > 0 후 별도 감소 음수가 되면 quiescent() 가 영원히 거짓 → 세대가 회수 불가
GrpcAdmissionController.tryAdmit·release 경계 초과, 그리고 경계가 영구히 느슨해짐
GrpcStreamAdmission.tryAdmit·release 〃 + caller 별 맵이 줄지 않음 재접속 폭풍에서 가장 많이 샌다

같은 저장소 안에 정본이 둘 있다 — GrpcRetryBudget.tryConsumeGrpcHedgingBudget.tryConsume 이 정확한 비교 후 교체 루프다. 그리고 GrpcDemandController 는 같은 형태를 synchronized 로 닫는다. 즉 이 저장소는 올바른 형태를 알고 있고, 다섯 곳에서만 쓰지 않았다.

사이클 2 가 더한 것 — 선언되고 주입되지 않는 검증기 (4곳).

StartupProfileValidation 의 javadoc 이 이 형태를 이미 이름 붙였다 — "the context published a validator per broker and validated nothing." 그 수정이 messaging 에 적용됐는데, 같은 형태가 네 곳에 남아 있다.

검증기 상태
GrpcPlatformStartupValidator 호출자 0 (자기 테스트 제외)
KafkaTransactionProfileValidator 빈으로 발행되고 StartupProfileValidation 에 감싸이지 않음
GrpcApplicationBoundaryRules 저장소 소스에 적용하는 코드 없음
GrpcRawApiImportRule 〃 (테스트가 인라인 문자열만 판정)

재통독이 다섯 곳을 더 찾았다. 그리고 그중 셋은 앞의 넷보다 무겁다 — 문서가 그 검증기를 "빌드를 실패시키는 것" 이라고 단언하기 때문이다. 아무도 부르지 않는 검증기는 공백이고, 부른다고 적힌 채 아무도 부르지 않는 검증기는 오해다.

검증기 / 게이트 상태 그렇게 적은 곳
GrpcProtoContractValidator 코드 호출자 0. *.gradle·*.kts·*.yml 어디에도 없음 buf.yaml:3-5GrpcBufPolicy:8-10 이 각각 "이것이 이 저장소의 빌드를 실패시킨다" 고 적는다
GrpcBufPolicy 의 네 수명주기 태스크 bufFormatCheck·bufLint·bufBuild·bufBreaking 이 어떤 빌드 파일에도 없음 javadoc 이 "a missing stage is a test failure rather than a stage nobody noticed was gone" 라고 적는다. 테스트는 목록을 리터럴·자기 자신과 비교한다
GrpcAdvancedModuleGuard.requireStableStarterIsClean 호출자 0 javadoc 이 "a runtime that was assembled some other way — a fat jar, a shaded artifact, a test harness — is checked too" 라고 적는다
NatsJetStreamProfileValidator 코드 호출자 0 · 테스트 0. 흔적은 javadoc {@link} 한 줄 NatsJetStreamTransport:35 가 "refuses the combination at startup" 이라고 적는다
PulsarProfileValidator 저장소 전체에서 자기 선언 한 줄 말고 아무 데도 없음 그 리프 SSOT §4 가 "검증기가 합의를 요구한다" 로 서술했다(이번에 정정)

GrpcProtoContractValidatorGrpcBufPolicy 는 서로를 가리킨다 — buf.yaml 은 CLI 가 없으니 자바 검증기가 게이트라고 하고, GrpcBufPolicy 는 태스크 이름이 CI 의 계약이고 자바 검증기가 실제 게이트라고 한다. 두 쪽 다 상대가 게이트라고 말하고, 어느 쪽도 실행되지 않는다.

형태의 이름. 앞의 넷은 "만들어졌지만 조립되지 않음"(§3.1 A)이고, 이 다섯은 "조립되었다고 적힌 채 조립되지 않음" 이다. A 와 F(문서가 코드보다 앞섬)가 같은 지점에서 겹치는 자리이고, 이 저장소의 주석 밀도가 높기 때문에 겹칠 때 특히 비싸다 — 읽는 사람이 게이트의 존재를 근거 있게 믿게 된다.

3.8 H — 선언만 있고 코드가 닿지 않는 project 의존 (재통독 신설, 6곳)

리프 SSOT 는 자기 build.gradle 을 읽지만, "이 의존이 실제로 쓰이는가" 는 그 리프의 import 를 전수로 봐야 나온다. 재통독이 그것을 리프마다 확인했고 여섯 곳이 나왔다.

리프 선언 실제 import
grpc-advanced-edition grpc-core-api · grpc-proto-contract · grpc-advanced-bootstrap 0dev.caskeleton import 가 한 줄도 없다
grpc-advanced-diagnostics 위 셋 중 grpc-client 포함 셋 bootstrap 만 4줄. grpc-core-api·grpc-client 0
grpc-spring-boot-starter grpc-proto-contract · grpc-codegen · grpc-operation-ledger-jpa (implementation) 셋 다 0
grpc-advanced-streaming · -compat grpc-advanced-bootstrap 둘 다 0
grpc-testkit · grpc-spring-boot-starter grpc-observability (api) 둘 다 0

api 로 선언된 것은 그 모듈을 쓰는 쪽까지 전파된다. grpc-observability 의 경우 Micrometer 가 두 모듈을 거쳐 전파되는데, 그 두 모듈은 관측 타입을 하나도 쓰지 않는다.

이 유형이 A 와 다른 점. A 는 코드가 있고 부르는 곳이 없는 것이고, 이것은 의존 그래프가 코드보다 넓은 것이다. 결과는 반대 방향으로 나타난다 — A 는 기능이 없는 것으로, 이것은 경계가 실제보다 느슨해 보이는 것으로. grpc-spring-boot-starter 는 이 저장소가 "구성 경계" 라고 이름 붙인 리프이므로, 그 리프의 의존이 실제 조립에 필요한 것보다 넓다는 사실은 그 이름이 주장하는 바에 직접 걸린다.

의도를 읽을 수 있는 경우도 있다 — grpc-advanced-editiongrpc-proto-contract 의존은 그 리프의 compatibility.proto 가 저쪽 스키마 규칙의 관할이라는 선언으로 읽힌다. 다만 그 관할은 코드로 연결되어 있지 않고, grpc-proto-contract 의 커밋 스키마 테스트가 파일 목록을 하드코딩해 이 파일을 판정하지 않는다. 즉 의존 선언이 표현하려던 관계가 실제로는 어느 쪽에도 없다.

3.6 F — 문서가 코드보다 앞서 있다 (18개 리프, 57건)

건수로는 A와 동률 1위인데 P1이 하나뿐이다. 대부분 javadoc·README·주석이 이제는 사실이 아닌 것을 말하는 형태다. adapter-outbound-httpclientBoundedDataBufferFlux가 javadoc이 처리한다고 적은 두 경로가 no-op인 것, adapter-outbound-fileserver의 README 주장이 여덟 개 port 구현 앞에서 성립하지 않는 것(P2)이 대표적이다.

이 저장소의 주석 밀도는 이례적으로 높고 — adapter-outbound-httpclientbuild.gradle은 이 저장소에서 가장 긴 근거 주석을 갖는다 — 그 밀도가 자산인 동시에 부채라는 것이 이 유형의 내용이다. 사고를 인용하는 주석은 그 사고를 다시 겪지 않게 하지만, 코드가 바뀔 때 함께 바뀌지 않으면 틀린 근거를 권위 있게 전달한다.


3.7 G — 전송 계열 가정 (사이클 2 신설)

EVD-332 가 사이클 1 의 P1 을 철회시킨 원인은 픽스처의 듀얼스택 호스트명이었다. 즉 IPv4 만 가정한 코드가 IPv6 가 있는 환경에서 다르게 동작한다 는 형태다. 통독이 같은 형태를 하나 더 찾았다.

GrpcDiagnosticsRedactor.maskAddress 는 IPv4 정규식 하나만 갖고, 맞지 않는 입력을 그대로 돌려준다. 그리고 스냅숏 생성자의 검사가 "마스킹 결과가 입력과 같으면 이미 마스킹된 것" 이므로, IPv6 주소·호스트 이름· 유닉스 소켓 경로가 전부 검사를 통과한다. 이 플랫폼이 겨냥하는 배포 형태가 쿠버네티스이고 헤드리스 레코드의 엔드포인트가 파드 DNS 이름이라는 점에서 도달 가능한 형태다.

두 사례의 공통점은 주소 표현의 다양성 이 아니라 판정의 방향 이다. 둘 다 "모르는 형태" 를 안전한 쪽이 아니라 통과 쪽으로 접었다.


4. 리프 경계를 넘을 때만 보이는 것

리프 SSOT가 원칙적으로 볼 수 없는 사실을 여기 모은다.

  1. adapter-inbound-grpc·adapter-inbound-websocket이 어떤 배포에도 없다. §2.
  2. adapter-outbound-objectstorage가 샘플에만 있다. 출하 애플리케이션에는 없다. §2.
  3. messaging-admin-api의 토폴로지 BLOCKING 보장을 배선하려면 인스펙터가 필요한데, messaging-kafka에는 줄 것이 없다. 두 리프의 문서가 각자 자기 쪽 절반만 볼 수 있다 — admin-api는 "@ConditionalOnBean(BrokerTopologyInspector)가 참이 된 적이 없다"를 보고, kafka는 "KafkaTopologyInspector 구현이 0"을 본다. 둘을 겹쳐야 같은 하나의 미배선이 된다.
  4. adapter-inbound-web의 P1 여섯 건은 web 리프가 아니라 app-bootstrap이 원인이다. web 문서가 그 사실을 스스로 지목하지만, 고칠 파일은 다른 리프에 있다.
  5. grpc 블록의 Stable/Advanced 경계는 실제로 강제된다. 사이클 2에서 한 번 반대로 판단했다가 grpc-spring-boot-starter/build.gradle:4-7의 근거 주석을 따라가 세 겹의 강제 (레지스트리 allowed_dependencies · verifyCleanArchitectureDependencies · GrpcPlatformStartupValidatorTest:257이 실제 build.gradle을 읽는 것)를 확인하고 철회했다 (EVD-326). 남은 것은 카탈로그의 이름 목록이 modules.json과 대조되지 않는다는 P3뿐이다.

  1. 같은 자료구조 오용이 두 리프에 있다. GrpcCredentialRotationManagerGrpcChannelRuntimeRegistry 가 각각 AtomicReference 를 조건 없는 set 으로 쓴다. 두 리프의 문서는 각자 자기 쪽만 볼 수 있고, 겹쳐야 "이 가족이 회전을 다루는 방식" 이라는 하나의 사실이 된다. §3.5.

  2. Kafka 트랜잭션 검증의 절반이 다른 리프에 있다. 검증기는 messaging-kafka 가 소유하고, 그것을 시작 시 부르는 배선은 messaging-spring-boot-starter 가 소유한다. 후자에 감싸는 블록이 없어서 전자가 돌지 않는다. 어느 쪽 문서도 혼자서는 "이 검증이 실행되지 않는다" 를 말할 수 없다.

  3. 정책 목록의 가장 강한 성질이 다른 리프의 미완성에 걸려 있다. GrpcMethodPolicyCatalog 의 서술자 대조는 이름 변경을 잡는 장치인데, 서술자를 만드는 grpc-codegen 이 protoc 을 돌리지 않으므로 이 저장소에서는 그 대조를 켤 수 없다. withDescriptorMethods 의 production 호출자는 0 이다.

  4. 같은 문제의 올바른 판본과 틀린 판본이 두 리프에 나란히 있다 — 결정을 그 결정이 판정한 대상에 묶는 것. grpc-codegenGrpcSchemaArtifactPublisher.publish(candidate, decision)decision.allowed() 만 보고 기록한다. PublishDecision 은 자기가 무엇을 판정했는지 들고 있지 않으므로, A 를 평가한 결정으로 B 를 발행할 수 있고 그러면 소비자 게이트와 버전 불변성을 둘 다 우회한다(그쪽 §17.4). grpc-advanced-bootstrapGrpcAdvancedSupportMatrix.apply(decision) 는 정반대다 — 결정의 from 이 현재 등급과 다르면 던지고, 그 이유를 "두 승격이 경합했거나 하나가 재생된 경우" 라고 적는다. 같은 저장소가 같은 형태를 한 번은 맞게, 한 번은 틀리게 썼다. §3.5 의 check-then-act 계열과 같은 뿌리이나 여기서는 경합이 아니라 인자 짝 맞추기가 깨진 자리다.

  5. "실환경 증거" 의 정의와 그 요구가 다른 리프에 있고 서로를 부르지 않는다. grpc-advanced-diagnosticsGrpcAdvancedInfrastructureTestkit 이 능력별로 무엇이 실환경인지 정의한다(gRPC-Web 프록시 · 서블릿 컨테이너 · 멈출 수 있는 xDS 통제 평면 · 코틀린 툴체인). grpc-advanced-bootstrapGrpcAdvancedPromotionEvidence 는 그것을 boolean realEnvironmentTest 하나로 받는다. 두 쪽이 만나지 않으므로 complete(XDS, 7일) 이 통제 평면 없이도 참을 넣는다 — 테스트킷 javadoc 이 경계한 "a suite that runs without the infrastructure passes and establishes nothing" 을 승격 게이트가 그대로 통과시킨다.

  6. 승격 기준이 같은 능력에 대해 두 게이트에서 다르다. EDITION_2024GrpcAdvancedPromotionGate 에서 증거 일곱 항목 + 7일 담금을 요구받고, GrpcEdition2024Gate 에서 호환성 보고서 + 소비자 이관 + ADR 을 요구받는다. 어느 쪽도 상대를 부르지 않고 관계가 문서에도 없다. 그리고 전자에는 별도 결함이 있다 — 30일 담금 갈래가 열거형에 없는 등급(STABLE_DEFAULT)을 위해 쓰여, ADVANCED_STABLE 이 아닌 목표 전부를 삼킨다. 그 결과 WATCH → EXPERIMENTAL(WATCH 가 밟도록 강제된 유일한 첫 걸음)이 30일을 요구하고 EXPERIMENTAL → ADVANCED_STABLE 은 7일을 요구한다 — 중간 등급이 상위 등급보다 어렵다. 그 갈래를 실행하는 테스트가 ADVANCED_STABLE → DISABLED(철회)를 골라 놓고 이름을 "becoming a Stable default" 라고 붙인 것이 그 뒤틀림의 흔적이다. (grpc-advanced-bootstrap §17.4)

  7. Stable 모듈 목록과 레지스트리를 붙드는 장치가 없다. GrpcStableModuleCatalog 의 javadoc 은 GrpcStableModuleCatalogTest 가 둘을 함께 붙든다고 적지만, 그 테스트는 modules.json 을 읽지 않고 목록을 리터럴과 대조한다. 두 집합은 오늘 일치한다(12 + 6 = 레지스트리의 grpc 계열 18). 어긋난 것은 그 일치를 무엇이 지키는가다. messaging 가족이 같은 형태를 이미 기록했다 — "세는 순간 다시 drift 한다."


5. 측정 방법에 대해 이 사이클이 배운 것

사이클 2가 만든 판정 번복 한 건과 자기 교정 세 건은 모두 같은 형태의 실수에서 나왔다. 코드를 읽고 런타임의 모양을 추론한 뒤, 그 추론을 측정으로 확인하지 않은 것.

  • EVD-332 — 분기 순서를 읽고 예외 사슬의 모양을 단정했다. 사슬을 출력하니 달랐다.
  • EVD-326 — 레지스트리에 검사가 없다고 단정했다. build.gradle의 주석이 가리키는 세 곳을 따라가니 있었다.
  • PulsarPreSendRejection — 전역 승인 게이트가 임계값을 읽을 것이라고 가정했다. PayloadLimitGuard는 그 필드를 읽지 않았다.
  • outbox 무제한 DELETE의 서술 — "프로덕션 기본 경로"라고 적었다. 자동설정은 스케줄러를 등록하지 않는다.

네 건 모두 하나의 추가 측정이면 갈렸다. 이 저장소의 코드는 자기 근거를 주석으로 남기는 밀도가 높아서 읽는 것만으로 확신이 생기기 쉽고, 바로 그 점이 함정이다. 사이클 3에 남기는 규칙은 하나다 — 런타임의 모양에 대한 주장은 런타임에서 확인한다. jshell로 사슬을 출력하는 데 든 비용은 몇 분이었고, 그 몇 분이 P1 하나를 지웠다.


6. 확인하지 못한 것

  • 다른 두 전송 분류기. ReactorFailureClassifier·JdkFailureClassifier는 개별 실행으로 확인하지 않았다. EVD-332가 반증한 것은 "Apache가 TLS 실패를 연결 예외로 감싼다"는 전제이므로 그 전제에 기대던 열린 항목은 소멸하지만, 두 분류기 자체를 측정한 것은 아니다.
  • jq가 있는 기계에서의 verify-compose-profile-contracts.sh. 파이썬 이식본으로 15개 레인이 계약과 일치함을 확인했으나, 이는 테스트 자신이 경계한 "두 번째 의견"이다. 원본 스크립트 실행이 정본이다.
  • 422건 중 268건의 기계 분류. 제목이 짧아 유형 분류가 되지 않았다. §3의 수치는 하한이다. 그리고 그 분류는 재통독 이전의 422건에 대해 돌린 것이다. 23개 리프가 60→100 으로 늘어난 뒤 다시 돌리지 않았으므로, §3 의 유형별 건수는 새로 추가된 40건을 반영하지 않는다. §3.5 의 검증기 표와 §3.8 은 기계 분류가 아니라 재통독에서 직접 확인해 손으로 적은 것이다.
  • 전체 462건의 직접 재측정. §0 이 밝힌 대로 462는 델타 조정값이고 61개 문서를 다시 센 값이 아니다.
  • sample-portfolio. 레지스트리에서 EXCLUDED이며 이 사이클의 분석 대상이 아니다.
  • 런타임 컴포지션의 실제 기동. compose 계약은 정적으로만 검증했다. 아무 스택도 기동하지 않았다.
  • 사이클 2 통독이 만든 92건의 실행 확인. 전부 코드 통독과 정적 대조로 판정했다. 두 가족 모두 배선 경로가 없거나(grpc 18개 리프) 선택할 수 없어서(rabbit), 실행으로 재현할 대상이 애초에 없다. 예외는 grpc-testkit 의 세 레인으로, 이번에 직접 돌려 통과를 확인했다(계약 7 · Netty 9 · 고장 9).
  • 재통독의 도달성 판정 방법. 리프마다 grep 으로 타입 이름·패키지 이름을 훑어 리프 밖 참조를 셌다. 리플렉션·서비스 로더·문자열 기반 조립으로 닿는 경로가 있다면 이 방법으로는 잡히지 않는다. 이 저장소가 그런 조립을 쓰는 곳은 발견하지 못했으나, 찾아본 것이 아니라 마주치지 않은 것이다.
  • 23개 리프의 테스트 실행. 재통독은 테스트 본문을 전부 읽었지만 이번 판에서 다시 돌리지는 않았다. "이 단언은 항상 통과한다" 류의 판정(messaging-nats-experimental §17.4 등)은 단언 의미론으로 내린 것이다. 예외는 grpc-testkit 의 세 레인으로, 앞선 판에서 직접 돌려 통과를 확인했다.
  • @ConditionalOnBean 사슬의 실제 평가. MessagingReliabilityAutoConfiguration 이 같은 클래스 안에서 방금 선언한 빈을 조건으로 삼는다. 지금은 그 앞 조건이 만족되지 않아 셋 다 만들어지지 않으므로 결과가 드러나지 않는다. 스프링의 문서화된 제약으로 판정했고 컨텍스트로 재현하지 않았다.

사이클 1 초안이 열린 질문 다섯 개를 번호로 관리했다. 그 제목이 candidate-ledger.json에 보존되어 있어 아래에 그대로 되살린다 — 초안 본문은 복구되지 않았으므로, 각 항목의 내용은 사이클 2가 실제로 확인한 것과 확인하지 못한 것으로 다시 썼다.

남은 질문 1 — 컨테이너·브로커·DB가 필요한 레인의 실제 결과

사이클 2는 Docker 가용을 확인하고(client 29.1.3 / server 29.6.1) Testcontainers 레인을 실제로 돌렸다 — persistence-jpa 477, persistence-mongo 72, cache-redis 435 테스트가 전부 통과했고, kafka 인증 레인과 실 브로커 왕복, Postgres IT, grpc-testkit 엄격 레인 넷도 실행했다. 그러나 전부는 아니다. jpaPlatformFailureTest는 이 리비전에서 실행하지 않았고(§6의 커밋 모호성 항목), mongo 쪽 컨테이너 레인 중 릴리스를 막지 않는 것들도 실행하지 않았다. 어느 레인이 실행됐고 어느 레인이 아닌지는 EVD-334가 목록으로 갖는다.

남은 질문 2 — sample-portfolio 내부

레지스트리에서 EXCLUDED이며 사용자 지시에 따라 이 사이클의 분석 대상이 아니다. 이 리프에만 속하는 adapter-outbound-objectstorage가 출하 애플리케이션에 없다는 사실(§2)은 레지스트리 대조로 확인했지만, 샘플 내부의 도메인 모델과 그 조립은 읽지 않았다.

남은 질문 3 — 런타임 관측

compose 계약은 정적으로만 검증했다. 어떤 스택도 기동하지 않았고, 애플리케이션을 부팅해 액추에이터나 조건 평가 리포트를 읽지도 않았다. 부팅 한 번이면 확증되는 정적 추론이 최소 두 건 남아 있다 — messaging 관측 시리즈 부재와 @ConditionalOnBean 사슬의 실제 평가 결과다.

남은 질문 4 — @ConditionalOnBean 실제 평가 순서

@ConditionalOnBean이 클래스 파싱 시점에 평가되어 빈이 사라지는 계열의 판정은 코드와 javadoc의 사후 기록에 근거한다. 이 리비전에서 ConditionEvaluationReport를 읽어 실제 평가 순서와 결과를 확인하지 않았다. debug=true로 부팅 한 번이면 확인된다.

남은 질문 5 — 성능·용량 주장

이 사이클은 성능을 측정하지 않았다. 문서에 남은 성능·용량 관련 서술은 전부 코드가 선언한 상한과 그 강제 여부에 대한 것이며, 실제 처리량·지연·자원 사용에 대한 주장은 하지 않는다. httpclient의 성능 레인과 jmh 벤치마크는 기계 의존적이라는 이유로 check에서 빠져 있고, 이 사이클에서도 돌리지 않았다.

7. 이 사이클의 작업 제약

  • 애플리케이션 소스는 한 줄도 수정하지 않았다.
  • 패키지를 설치하지 않았다(jq 포함).
  • 사용자 워크스페이스의 빌드 산출물을 삭제하지 않았다.
  • git 상태를 변경하지 않았다 — 커밋·푸시·리셋·클린 없음.
  • 컨테이너를 기동하지 않았다. Testcontainers를 쓰는 lane은 저장소 자신의 테스트가 기동한 것이다.

Source anchors

src/config/architecture/modules.json                     (62개 리프의 runtime_memberships)
src/build.gradle:477-492                                 (사이클 1→2 사이 변경 지점)
src/grpc/CLAUDE.md:74-98                                 (grpc 블록 build-only 상태)
src/grpc/grpc-spring-boot-starter/build.gradle:4-7       (Stable/Advanced 경계 근거)
scripts/verify-compose-profile-contracts.sh:20-30,42-160
src/config/runtime/compose-profile-contracts.json        (15개 레인)

§A01 … §A18        (18개 리프 SSOT)
analysis/messaging/*.md                                          (25개)
analysis/grpc/*.md                                               (18개)
§A19, §A20   (가족 문서, INTEGRATION_ONLY)

evidence/raw/325-grpc-block-is-entirely-unreachable.txt
evidence/raw/326-grpc-stable-advanced-boundary-has-no-registry-check.txt
evidence/raw/328-grpc-boundary-rules-never-see-real-source.txt
evidence/raw/329-grpc-two-rule-engines-one-fed.txt
evidence/raw/332-httpclient-dualstack-localhost-masks-tls-permanent.txt
evidence/raw/333-eighteen-docs-source-drift-zero.txt
evidence/raw/334-eighteen-leaf-lane-rerun.txt

A19-MESSAGING-ADMIN-API. messaging-admin-api

분석 중에는 messaging/MESSAGING-ADMIN-API.md 파일이었다. 1,045줄.

messaging-admin-api 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-admin-api SSOT owner: messaging-admin-api integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-admin-api
  • canonical state analysisFile: §A19-MESSAGING-ADMIN-API
  • source path: src/messaging/messaging-admin-api
  • registry allowed_dependencies: ["messaging-core-api", "messaging-policy"]
  • registry runtime_memberships: ["app-bootstrap"] — 배포된다
숫자
항목
production Java 파일 24
test Java 파일 1
전체 LOC 1,760
패키지 1 (dev.caskeleton.messaging.admin)
test 메서드(실행 확인) 9 (EVD-305)
선언된 의존 project 2
실제 import되는 의존 project 2 (core-api O, policy X — §12.4)
leaf 밖에서 import 하는 파일 21 (4개 모듈)

24 타입을 역할로 묶으면 다섯 덩어리다.

덩어리 타입
승인 신원·서명 AdminApproval, ApprovalGrant, ApprovalVerifier, HmacApprovalVerifier, VerifiedApproval, PlanDigest 6
파괴적 작업 게이트 DestructiveOperation, DestructiveOperationGuard 2
리플레이 ReplayRequest, ReplayPlan, ApprovedReplayPlan, ReplayResult 4
리드라이브 RedriveRequest, RedrivePlan, ApprovedRedrivePlan, RedriveResult 4
실행 저널 AdminOperationJournal, AdminOperationRecord, AdminOperationLease, AdminOperationState 4
토폴로지 TopologyManifest, DestinationTopology, TopologyIssue, TopologyValidationReport, TopologyManagementMode 5
Coverage ledger
scope/file group count disposition reason
src/main/java/** (24) 24 FULL_READ 전 파일 본문 확인
src/test/java/** (1) 1 FULL_READ 147줄, 9개 테스트
build.gradle 1 FULL_READ 6줄
하류 소비자 (starter·admin-runtime) STRUCTURAL_ONLY 도달성 판정에 필요한 범위만. SSOT 는 각 리프 소유
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

되돌릴 수 없는 작업을 사람의 승인에 묶는 타입 집합이다. 실행 코드는 하나도 없다 — 브로커를 만지는 것도, 메시지를 옮기는 것도 전부 messaging-admin-runtime 과 어댑터가 한다. 이 리프가 정의하는 것은 "무엇이 승인이고, 승인이 무엇을 인가하며, 인가되지 않은 것이 왜 컴파일되지 않는가" 다.

설계의 축은 하나다: 권한을 불리언이 아니라 타입으로 만든다.

// VerifiedApproval.java:6-17
/**
 * A grant that an {@link ApprovalVerifier} has checked, and that nobody else can manufacture.
 *
 * <p>The approved-plan types used to hold a plain {@code AdminApproval} record with a public
 * constructor, so "this plan was approved" was a claim the caller made about itself. Any code that
 * could reach the execute method could write {@code new AdminApproval("TICKET-1", "someone", now,
 * later)} and the platform believed it.
 *
 * <p>This class has no public constructor and no public factory. The only way to obtain one is to
 * present a grant and a signature to a verifier, which is exactly the property the type is for:
 * holding a {@code VerifiedApproval} is itself the evidence that verification happened.
 */

이 문단이 이 리프 전체의 요약이다. 그리고 그 위에 같은 기법이 한 층 더 쌓인다.

// ApprovedReplayPlan.java:8-13
/**
 * A replay plan that a named human has approved.
 *
 * <p>A distinct type from {@link ReplayPlan} rather than a boolean on it. The execute method takes
 * this type, so an unapproved plan cannot reach it — the authorisation is enforced by the compiler
 * instead of by a runtime check somebody can forget to write.
 */

ReplayPlanApprovedReplayPlan → 실행. 각 화살표가 타입 경계이고, 각 경계에서 검사가 생성자 안에 있어 우회 경로가 없다.

경계 밖: 이 리프는 브로커를 모른다(DestinationTopology 는 브로커가 보고한 값을 담는 record 일 뿐 조회하지 않는다), 저장소를 모른다(AdminOperationJournal 은 인터페이스), 스프링을 모른다.


2. 의존성과 런타임 배선

// messaging-admin-api/build.gradle 전문
apply plugin: 'java-library'

dependencies {
    api project(':messaging:messaging-core-api')
    api project(':messaging:messaging-policy')
}

messaging-core-api 에서 쓰는 것: DestinationName, MessageAuthorizationException, MessagingConfigurationException. messaging-policy 는 import 0건이다(§12.4).

leaf 밖 소비자 21개 파일 / 4개 모듈:

모듈 src/main src/test 역할
messaging-admin-runtime 10 6 실제 실행·검증 서비스
messaging-spring-boot-starter 2 0 빈 배선 + 저널 내구성 검사
messaging-outbox-jdbc-postgresql 1 1 JdbcAdminOperationJournal
messaging-kafka 0 1 KafkaTopologyValidationIT

부팅된 애플리케이션에서 이 리프의 타입 중 실제로 살아나는 것은 둘뿐이다(EVD-302, EVD-303).

// MessagingAdminAutoConfiguration.java (@ConditionalOnProperty app.messaging.admin.enabled=true)
:37  DestructiveOperationGuard destructiveOperationGuard()  -> new DestructiveOperationGuard(false)
:56  AdminOperationJournal adminOperationJournal()          -> new InMemoryAdminOperationJournal()
:69  MessagingAdminDurabilityValidator                     (InitializingBean)
:83  CompositeTopologyValidator compositeTopologyValidator(BrokerTopologyInspector)

MessagingAdminService 빈은 없고 ApprovalVerifier 빈도 없다. 이는 명시된 설계다.

// MessagingAdminAutoConfiguration.java:14-24
/**
 * Wires the admin plane, and only when a deployment has explicitly asked for it.
 *
 * <p>Off unless {@code app.messaging.admin.enabled=true}. An application that acquires the admin
 * plane by adding a starter to its classpath is exactly the situation the plane's guards exist to
 * prevent …
 *
 * <p>{@link …DestructiveMessagingAdmin} is deliberately absent from this class. No bean for it is
 * ever auto-configured: an operator tool that needs purge or delete registers one itself, with an
 * admin credential this runtime does not hold.
 */

그러나 이 스탠스가 토폴로지 검증까지 덮지는 않는다 — §12.1 과 §17 의 첫 항목이 그것이다.


3. 패키지/컴포넌트 지도

단일 패키지. 신뢰 흐름은 왼쪽에서 오른쪽으로만 흐른다.

  [변경관리 시스템 = 발급자]           issuingKey 보유
        |  ApprovalGrant 조립 (approver != operator 가 생성자에서 강제)
        |  HmacApprovalVerifier.sign(grant)  ->  signature
        v
  (grant, signature)  전달
        |
        v
  [실행 런타임]  ApprovalVerifier.verify(grant, signature, executingDigest, now)
        |    - planDigest == executingDigest ?
        |    - 윈도우 열려 있나 ?
        |    - HMAC 상수시간 일치 ?
        v
  VerifiedApproval           <- 생성자 private, 팩토리 package-private
        |
        +--> ApprovedReplayPlan  / ApprovedRedrivePlan   (생성자에서 4가지 재검사)
        |        |  requireExecutable(now, currentTopologyVersion)
        |        v
        +--> DestructiveOperationGuard.authorize(...)     (6가지 검사)
                 |
                 v
             [실행 — 이 리프 밖]
                 AdminOperationJournal.begin/checkpoint/complete/fail

같은 검사가 두 자리에 나타나는 것은 중복이 아니라 의도된 이중화다(§12.3(a)).


4. 계약·불변식·상태 모델

4.1 ApprovalGrant — 서명되는 것의 전부
// ApprovalGrant.java:8-18
/**
 * Everything an issuer signs when granting one destructive operation.
 *
 * <p>{@link AdminApproval} carried a ticket, an approver, and a window. Nothing in it said which
 * operation, on which destination, against which topology, or up to what impact — so the same
 * approval authorised every plan presented inside the window, and the audit trail recorded a ticket
 * that proved nothing about what was executed.
 *
 * <p>Every field here is part of the signed canonical form, which is what makes the grant
 * non-transferable: change the target, the topology version, the impact ceiling, or the plan, and
 * the signature no longer verifies.
 */

8개 필드. 그중 requestedBy 가 생성자에서 4-eyes 를 강제한다.

// ApprovalGrant.java:50-55
if (approval.approvedBy().equals(requestedBy)) {
  // Four-eyes, expressed where it cannot be skipped: an operator approving their own
  // destructive operation is the control not existing.
  throw new IllegalArgumentException(
      "the approver and the operator must be different people; both are " + requestedBy);
}

"우회할 수 없는 자리에 표현했다" — 검사기가 아니라 record 생성자에 두었으므로, 서명 대상 객체가 존재하는 것 자체가 4-eyes 통과를 뜻한다.

canonicalForm() 은 길이 접두 인코딩이다.

// ApprovalGrant.java:61-87
/**
 * <p>Every field is length-prefixed rather than delimited. A delimiter can be smuggled into a
 * ticket or an identity to make two different grants render identically; a length prefix cannot.
 */
public String canonicalForm() {
  StringBuilder canonical = new StringBuilder("v1");
  appendField(canonical, approval.ticket());
   11개 필드 
}
private static void appendField(StringBuilder canonical, String value) {
  canonical.append(value.length()).append(':').append(value);
}

버전 접두 "v1" 이 앞에 있어 형식 교체 여지를 남긴 것도 의도적으로 보인다. 이 규칙이 계획 다이제스트 쪽에는 적용되지 않았다 — §12.3(b).

4.2 HmacApprovalVerifier — 대칭키를 고른 이유와 그 대가
// HmacApprovalVerifier.java:12-24
/**
 * Verifies grants with a shared HMAC-SHA256 issuing key.
 *
 * <p>A shared secret rather than a public-key signature is a deliberate scope choice: the issuer is
 * the change-management system operated by the same organisation as the runtime, so key
 * distribution is already solved and the property that matters — that an operator cannot mint an
 * approval for themselves — holds as long as the key is not on the operator's machine. A public-key
 * verifier can be substituted by implementing {@link ApprovalVerifier}; nothing else in the
 * platform depends on how the signature is produced.
 *
 * <p>Comparison is constant-time. A verifier that compares with {@code equals} leaks the signature
 * one byte at a time to a caller who can retry, which for an offline attacker with unlimited
 * attempts is the whole secret.
 */

세 가지가 코드로 지켜진다.

// :39-43  키 길이 하한
if (issuingKey.length < 32) {
  throw new IllegalArgumentException(
      "the approval issuing key must be at least 256 bits; a shorter key makes the signature"
          + " brute-forceable offline");
}
// :44  방어적 복사
this.issuingKey = issuingKey.clone();
// :92  상수시간 비교
if (!MessageDigest.isEqual(mac(grant.canonicalForm()), presented)) {  }

검증 순서도 의미가 있다: 다이제스트 대조 → 윈도우 → 16진 파싱 → HMAC. 다이제스트를 먼저 보는 이유가 주석에 있다.

// :71-79
if (!grant.planDigest().equals(executingDigest)) {
  // The signature would verify: this is a genuine approval, for a different plan. Replaying one
  // approval across plans is the attack the digest binding exists to stop.
  throw new MessageAuthorizationException("APPROVAL_PLAN_MISMATCH", );
}

sign(...) 이 같은 클래스에 public 으로 있고, javadoc 이 그 위험을 스스로 명시한다.

// :47-57
/**
 * Produces the signature an issuer would attach to a grant.
 *
 * <p>Present because the issuer and the verifier must agree on the canonical form exactly, and a
 * second implementation of that agreement is a second thing to drift. Holding this key is what
 * makes a caller an issuer — it is not, and must not become, available to the runtime that
 * executes operations.
 */

이 문장과 대칭키 선택이 만드는 구조적 결과가 §17 의 한 항목이다: 검증하는 쪽은 반드시 서명할 수도 있다.

4.3 DestructiveOperationGuard — 여섯 개의 검사
// DestructiveOperationGuard.java:9-19
/**
 * The single gate every destructive messaging operation passes through.
 *
 * <p>Four conditions, all required. The caller must hold the admin credential — an application
 * runtime does not, by construction. The approval must be present and inside its validity window.
 * And a dry run is always permitted, because the way to make operators plan before they act is to
 * make planning free.
 *
 * <p>Centralised so that adding a new destructive operation means adding an enum constant, not
 * remembering to re-implement the checks.
 */

실제 분기는 여섯이다.

# 조건 코드 javadoc 에 서술됨
1 dryRun → 통과 :54-56 O
2 관리자 자격증명 없음 :57-61 O
3 승인 부재 :62-67 O
4 윈도우 밖 :68-71 O
5 operation 불일치 :72-83 X
6 source 불일치 :84-93 X

5번에는 별도 인라인 주석이 있어 의도된 검사임이 분명하다.

// :72-75
if (granted.grant().operation() != operation) {
  // The approval verified — for something else. A guard that only checks presence and window
  // lets a verified redrive approval authorise a destination deletion.

"검사는 있는데 클래스 javadoc 이 세지 않는" 두 항목이, 동시에 어떤 테스트에도 도달하지 않는 두 항목이다(EVD-303, §12.1).

"dry run 은 항상 허용" 의 근거도 적혀 있다 — "the way to make operators plan before they act is to make planning free". 이것은 보안 완화가 아니라 행동 설계다.

4.4 계획 → 승인된 계획: 생성자에서 네 가지, 실행 직전에 세 가지

ApprovedReplayPlan 생성자(:19-52):

검사 실패 코드
approval.grant().planDigest() == plan.digest() APPROVAL_PLAN_MISMATCH
operation == REPLAY APPROVAL_OPERATION_MISMATCH
grant().source() == plan.request().destination() APPROVAL_SOURCE_MISMATCH
plan.estimatedMessages() <= grant().maxImpact() APPROVAL_IMPACT_EXCEEDED

requireExecutable(now, currentTopologyVersion)(:54-85):

검사 실패 코드
승인 윈도우 APPROVAL_EXPIRED
grant().topologyVersion() == 현재 TOPOLOGY_CHANGED_SINCE_APPROVAL
plan.topologyVersion() == 현재 TOPOLOGY_CHANGED_SINCE_APPROVAL

토폴로지를 두 번 보는 이유가 주석에 있다.

// ApprovedReplayPlan.java:76-84
if (!plan.topologyVersion().equals(currentTopologyVersion)) {
  // Every number in the plan was computed against the old topology, so the approver agreed to
  // an impact estimate that no longer describes what would happen.

승인이 서명된 토폴로지와 계획이 계산된 토폴로지가 다를 수 있으므로 둘 다 현재와 대조한다.

ApprovedRedrivePlan 은 같은 네 검사에 더해 loopAcknowledged 를 별도 필드로 갖는다.

// ApprovedRedrivePlan.java:8-19
/**
 * <p>Carries {@code loopAcknowledged} separately from the approval itself. Approving a redrive of
 * 900 parked messages and approving a redrive that will re-fail 400 of them are different
 * decisions, and the second one needs the approver to have seen the number …
 */

그리고 requireExecutable 의 마지막 분기가 그것을 강제한다.

// :87-93
if (plan.risksALoop() && !loopAcknowledged) {
  throw new MessageAuthorizationException(
      "REDRIVE_LOOP_NOT_ACKNOWLEDGED",
      "%d of the %d candidates already failed a previous redrive; re-running them without "
              .formatted(plan.alreadyRedrivenCandidates(), plan.candidates())
          + "fixing the cause produces a loop that looks like progress");
}

"진행처럼 보이는 루프" 는 이 리프에서 반복되는 관점이다 — 대시보드에서 옳아 보이는 실패를 타입으로 막는다.

4.5 실행 저널 — 리스와 펜싱 토큰
// AdminOperationJournal.java:7-18
/**
 * <p>The implementation this replaced was a {@code ConcurrentHashMap} registered by the starter as
 * the default. Two consequences followed, and both are worse than having no store at all because
 * the map made the platform look protected. A restart forgot every claim, so the same approval
 * could be executed again by the same process. And two replicas each had their own map, so both
 * could claim the same approval at the same moment and each believe it was the only one.
 *
 * <p>Implementations must therefore be shared and durable, must enforce uniqueness on {@code
 * (approvalTicket, planDigest)}, and must hand out leases with a monotonic fencing token so a
 * process that stalled past its lease cannot write over the replica that took over from it.
 */

키를 (approvalTicket, planDigest) 로 잡은 이유:

// AdminOperationRecord.java:9-12
/**
 * <p>Keyed by {@code (approvalTicket, planDigest)} rather than by the ticket alone, because the
 * ticket alone cannot distinguish "this approval already ran" from "this approval is being reused
 * for a different plan" — and those need opposite answers.
 */

리스가 불리언이 아니라 resumeFrom 을 나르는 이유:

// AdminOperationLease.java:8-11
/**
 * <p>{@code resumeFrom} is the whole reason a lease is handed out rather than a boolean. A retry
 * after a crash is not a new execution of the approval — it is the same operation continuing, and
 * treating it as new is what republishes the items the first attempt already moved.
 */

상태 세 개(STARTED/COMPLETED/FAILED)를 만든 이유:

// AdminOperationState.java:5-9
/**
 * <p>The store this replaces recorded one fact — "this approval was claimed" — and recorded it
 * before any work happened. A run that died halfway had consumed its approval, left no record of
 * how far it got, and offered the operator two equally bad choices: request a fresh approval and
 * redo work that may already have been done, or leave the operation half-applied.
 */

isDurable() 을 인터페이스에 둔 이유도 명시적이다 — "Declared rather than inferred so the starter can refuse to run a production profile on an in-memory journal instead of discovering the gap during an incident." 이 선언은 실제로 배선되어 있다(§12.2 의 대조군).

4.6 토폴로지 — 선언과 실측을 다른 타입으로
// DestinationTopology.java:6-11
/**
 * What the broker actually reports for one destination.
 *
 * <p>The counterpart to {@link TopologyManifest}: the manifest is what was declared, this is what
 * exists. Kept as a separate type rather than reusing the manifest so that a comparison cannot
 * accidentally compare a manifest with itself and report success.
 */

"자기 자신과 비교해서 성공을 보고하는 것" 을 타입으로 막았다 — messaging-testkit 의 인증 행렬이 고친 결함과 정확히 같은 형태이며, 여기서는 처음부터 타입으로 예방했다.

심각도를 finding 에 붙인 이유:

// TopologyIssue.java:8-12
/**
 * <p>Severity is part of the finding because the two kinds behave differently at startup. A {@link
 * Severity#BLOCKING} issue means the destination cannot deliver its declared guarantee —
 * replication factor 1 on a destination promising durability is not a warning, it is a promise the
 * platform cannot keep — so the context refuses to start. …
 */

전부 모아 보고하는 이유:

// TopologyValidationReport.java:10-12
/**
 * <p>Reports both severities together rather than failing on the first blocking issue. An operator
 * fixing a topology wants the whole list — fixing one attribute, redeploying, and discovering the
 * next one is how a ten-minute fix becomes an afternoon.
 */

두 문장 모두 "기동을 거부한다" 를 전제한다. 그 전제가 배선되지 않았다 — §12.1.


5. 주요 실행 경로

경로 A — 계획 (승인 불필요, dry run 무료)

ReplayRequest / RedriveRequest      (생성자에서 창 역전·자기참조·배치 상한 검사)
  -> [admin-runtime] 브로커 조회로 추정치 계산
  -> ReplayPlan / RedrivePlan       (topologyVersion 캡처)
  -> plan.digest()                  -> PlanDigest (SHA-256)
  -> plan.describeImpact()          -> 운영자에게 보여줄 한 줄  (프로덕션 소비자 없음, §12.1)

경로 B — 승인 발급 (이 리프 밖, 변경관리 시스템)

ApprovalGrant(approval, requestedBy, operation, source, target,
              topologyVersion, maxImpact, planDigest)   <- 생성자에서 4-eyes
  -> HmacApprovalVerifier.sign(grant)  -> hex signature

경로 C — 실행

verifier.verify(grant, signature, plan.digest(), now)      -> VerifiedApproval
  -> new ApprovedReplayPlan(plan, approval)                 (4검사)
  -> approvedPlan.requireExecutable(now, inspector.topologyVersion())   (3검사)
  -> guard.authorize(operation, destination, Optional.of(approval), dryRun, now)  (6검사)
  -> journal.begin(ticket, digest, operationId, owner, leaseDuration, now)
       -> AdminOperationLease(resumeFrom = 이전 체크포인트)
  -> [실행] … journal.checkpoint(...) 반복 … journal.complete(...) 또는 fail(...)
  -> ReplayResult / RedriveResult   (dry run 은 0 이어야 함이 생성자에서 강제)

경로 C 에서 검사가 세 지점(verify / Approved*Plan / guard)에 걸쳐 겹친다. ApprovalVerifier javadoc 이 그 이유를 설명한다.

// ApprovalVerifier.java:8-12
/**
 * <p>An implementation must check, at minimum: the issuer's signature over {@link
 * ApprovalGrant#canonicalForm()}, that the approval window is open at {@code now}, and that the
 * digest the caller is about to execute is the digest that was signed. Everything else the grant
 * binds — operation, source, target, topology version, impact ceiling, approver-versus-operator
 * separation — is inside the canonical form, so a signature check covers them all at once.
 */

즉 서명 검증이 통과하면 나머지는 이미 보장되지만, ApprovedReplayPlan 생성자와 guard 가 같은 것을 다시 본다. 방어적 중복이며 §12.3(a) 에서 다시 다룬다.


6. 실패 경로와 복구/번역

전부 MessageAuthorizationException 또는 MessagingConfigurationException 이고, 코드가 붙어 있다.

코드 던지는 곳 의미
APPROVAL_PLAN_MISMATCH HmacApprovalVerifier:74, ApprovedReplayPlan:26, ApprovedRedrivePlan:28 진짜 승인, 다른 계획
APPROVAL_EXPIRED HmacApprovalVerifier:81, DestructiveOperationGuard:69, Approved*Plan:66/71 윈도우 밖
APPROVAL_SIGNATURE_INVALID HmacApprovalVerifier:89, :93 16진 아님 / HMAC 불일치
ADMIN_CREDENTIAL_REQUIRED DestructiveOperationGuard:58 애플리케이션 런타임
APPROVAL_REQUIRED DestructiveOperationGuard:65 승인 없음
APPROVAL_OPERATION_MISMATCH DestructiveOperationGuard:75, Approved*Plan:32/34 다른 작업의 승인
APPROVAL_SOURCE_MISMATCH DestructiveOperationGuard:85, Approved*Plan:38/41 다른 목적지의 승인
APPROVAL_IMPACT_EXCEEDED Approved*Plan:47/52 승인 상한 초과
TOPOLOGY_CHANGED_SINCE_APPROVAL Approved*Plan:70/74, :79/82 추정치 무효
REDRIVE_LOOP_NOT_ACKNOWLEDGED ApprovedRedrivePlan:88 루프 위험 미승인
AUTO_CREATE_IN_PRODUCTION TopologyManagementMode:30 프로덕션 자동 생성
TOPOLOGY_MISMATCH TopologyValidationReport:78 BLOCKING 존재

메시지가 전부 "무엇이 왜 거절되었는가" 를 서술형으로 쓴다. 예:

// ApprovedRedrivePlan.java:43-49
"approval %s authorises %s to %s, not %s to %s"

IllegalArgumentException구조적으로 불가능한 값에만 쓴다 — 음수 카운터, 빈 문자열, 역전된 윈도우, 4-eyes 위반. 인가 실패와 프로그래밍 오류가 예외 타입으로 갈린다.

toString() 하나가 로그 위생을 명시적으로 다룬다.

// VerifiedApproval.java:81-87
@Override
public String toString() {
  // Deliberately not the whole grant: this string reaches logs, and the plan digest plus the
  // ticket identify the operation without restating who approved what to an audience that has
  // not been authorised to read it.
  return "VerifiedApproval[" + ticket() + " " + grant.planDigest().value().substring(0, 12) + "]";
}

ApprovalGrant 는 record 라 기본 toString() 이 전 필드를 찍는다는 점은 대비된다 — 다만 ApprovalGrant 자체가 로그에 닿는 경로는 확인되지 않았다.


7. 트랜잭션·동시성·수명주기

이 리프에 실행 코드가 없으므로 동시성 계약은 전부 인터페이스 문서로 표현되어 있고, 강제는 구현 리프의 몫이다.

// AdminOperationJournal.java:16-18
 * Implementations must therefore be shared and durable, must enforce uniqueness on {@code
 * (approvalTicket, planDigest)}, and must hand out leases with a monotonic fencing token so a
 * process that stalled past its lease cannot write over the replica that took over from it.

펜싱 토큰의 하한이 타입으로 강제된다.

// AdminOperationLease.java:39-41 / AdminOperationRecord.java:59-61
if (leaseToken < 1) {
  throw new IllegalArgumentException("a lease token starts at one");
}

begin/checkpoint 의 javadoc 이 각각 던져야 할 조건을 명시한다 — begin 은 "already completed, or another runtime holds a live lease", checkpoint 는 "the lease has been taken over by a newer token". 즉 오래된 토큰의 쓰기를 거절하는 것이 구현 의무로 문서화되어 있다.

이 리프의 모든 타입은 record 이거나 불변 final 클래스다. DestructiveOperationGuardfinal boolean 하나만 갖고, HmacApprovalVerifier 는 키를 clone 해 보관한다. 공유해도 안전하다.

수명주기 훅은 없다. MessagingAdminDurabilityValidator(스타터, InitializingBean)가 유일한 기동 시점 훅이며 이 리프 밖이다.


8. 설정·기능 플래그·환경 차이

이 리프 자체에는 설정이 없다. 환경 차이를 만드는 지점은 셋이다.

스위치 위치 기본 효과
app.messaging.admin.enabled 스타터 @ConditionalOnProperty 미설정(=off) admin 빈 전체 on/off
adminCredentialPresent DestructiveOperationGuard 생성자 인자 스타터에서 false 고정 파괴적 작업 전면 거절
활성 프로파일 prod/production MessagingAdminDurabilityValidator 비내구 저널이면 기동 실패

TopologyManagementMode 는 설정처럼 보이지만 어떤 프로퍼티에도 묶여 있지 않다(§12.1).


9. 퍼시스턴스/외부 시스템 세부

직접 접점 없음. 두 개의 포트로만 표현된다.

  • AdminOperationJournal — 유일한 프로덕션 구현은 JdbcAdminOperationJournal(outbox-jdbc-postgresql). 개발용 InMemoryAdminOperationJournal(admin-runtime)은 isDurable()==false 를 선언하고, 스타터가 프로덕션 프로파일에서 그것을 거절한다.
  • DestinationTopology / TopologyManifest — 브로커가 보고한 값과 선언값의 대조.

TopologyManifest.differencesFrom(...)List<String> 을 돌려주고, CompositeTopologyValidatorTopologyIssue 를 만든다. 두 개의 비교 메커니즘이 공존한다(§12.3(c)).


10. 테스트 레인과 실제 증명 범위

EVD-305: ./gradlew :messaging:messaging-admin-api:test --rerun-tasks9 tests, 0 failures, 0 skipped.

production 타입 24개에 대해 자체 테스트가 파일 1개·9건이다. 그 9건이 직접 겨냥하는 타입은 셋뿐이다.

타입 테스트 내용
DestructiveOperationGuard 5 자격증명 없음 / 승인 없음 / 만료 / dry run / 통과
RedriveRequest 2 자기 참조 금지, 배치 상한 100
TopologyManifest 2 differencesFrom 차이 열거 / 일치

나머지 21개 타입의 불변식은 하류 리프의 테스트가 검증한다.

위치 테스트 겨냥
messaging-admin-runtime ApprovalForgeryTest 10 위조·변조·타 발급자·계획 불일치·소스 불일치·상한 초과·작업 불일치·만료·4-eyes·정상
messaging-admin-runtime ApprovedPlanExecutionTest 11 만료·토폴로지 변경·루프 승인·영향 서술·미달 신호·정산 누락·dry run
messaging-admin-runtime AdminOperationJournalTest 리스·재개·펜싱
messaging-admin-runtime TopologyValidatorTest BLOCKING/ADVISORY·requireAcceptable·requireSafeFor
messaging-kafka KafkaTopologyValidationIT 실제 브로커 대상 requireAcceptable

ApprovalForgeryTest.aVerifiedApprovalCannotBeConstructedOutsideTheVerifier 가 이 리프의 핵심 타입 속성을 직접 검증한다는 점은 좋다. 다만 SSOT 를 소유한 리프가 자기 불변식의 대부분을 하류에서 검증받는 구조이며, 그 결과 이 리프만 빌드·테스트해서는 24개 타입 중 3개만 증명된다.

증명되지 않는 두 분기가 있다(EVD-303): DestructiveOperationGuard 의 operation 불일치(:72-83)와 source 불일치(:84-93). §12.1 에서 상술한다.


11. 빌드/ArchUnit/CI 강제 지점

build.gradle 6줄이 전부다. 이 리프 고유의 게이트는 없다. 루트에서 오는 공통 게이트(Checkstyle, SpotBugs+findsecbugs, ErrorProne -Werror)만 적용된다.

주목할 점: VerifiedApproval 의 위조 방지가 package-private 에 의존하는데, 이 저장소는 JPMS 를 쓰지 않는다.

find src -name "module-info.java" -not -path "*/build/*" | wc -l
0

즉 어떤 모듈이든 package dev.caskeleton.messaging.admin; 을 선언한 클래스를 만들면 VerifiedApproval.of(grant) 를 부를 수 있다. 현재 그런 파일은 없다.

git grep -ln "^package dev.caskeleton.messaging.admin;" -- src | grep -v "messaging-admin-api/"
(출력 없음)

이것을 지키는 자동 검사(ArchUnit 규칙 등)는 이 리프에 없다. §17 의 P3 항목이다.


12. 실제 사용 여부와 negative-space probes

12.1 Public surface reachability

방법 주의. 이 리프의 메서드 이름은 저장소 전체와 충돌이 심하다. git grep "\.absent(" 는 60건, "\.blocking(" 은 19건, "\.authorize(" 는 48건을 내지만 대부분 fileserver·graphql·web 의 동명 메서드다. 아래 수치는 전부 소유 타입을 확인한 뒤 센 것이다(EVD-302, EVD-303).

멤버 src/main 호출 테스트 호출 판정
DestructiveOperationGuard.authorize 2 (RedriveService:97, ReplayService:59) 5 사용됨
HmacApprovalVerifier.verify / .sign 0 / 0 O 프로덕션 배선 없음
AdminOperationJournal.* O (admin-runtime, outbox-jdbc) O 사용됨
TopologyManifest.differencesFrom 1 (TopologyValidationRuntime:48) 2 사용됨
TopologyValidationReport.requireAcceptable 0 4 §17 첫 항목
TopologyManagementMode (타입 전체) 0 4 미배선
ReplayPlan.describeImpact 0 2 테스트 전용
RedrivePlan.describeImpact 0 (자기 내부 1) 2 테스트 전용
ReplayResult.fellShortOfTheEstimate 0 1 테스트 전용
RedriveResult.isFullyAccounted 0 2 테스트 전용
AdminOperationLease.isResumption 0 1 테스트 전용
RedrivePlan.risksALoop 1 (ApprovedRedrivePlan:87) 사용됨
Approved*Plan.requireExecutable 2 (DefaultMessagingAdminService:127, :178) O 사용됨

세 덩어리로 읽힌다.

(a) 토폴로지 보장이 배선되지 않았다. TopologyIssue.Severity.BLOCKING 의 javadoc 은 "the context refuses to start", requireAcceptable() 의 javadoc 은 "Fails startup when any blocking issue was found" 라고 쓴다. 그러나:

git grep -n "requireAcceptable" -- src
  선언 1 + 테스트 4.  src/main 호출 0건.
git grep -n "validateTopology" -- src
  MessagingAdminService:32 (선언) + DefaultMessagingAdminService:106 (구현).  호출 0건.

스타터는 CompositeTopologyValidator 빈을 만들지만 그것을 호출하는 빈을 만들지 않고, MessagingAdminService 빈 자체가 없다. 따라서 부팅된 애플리케이션에서 토폴로지 검증이 실행되는 경로가 없다.

대조군이 같은 파일에 있다: MessagingAdminDurabilityValidatorInitializingBean 이고 afterPropertiesSet()validate() → 프로덕션+비내구면 MessagingConfigurationException 을 던진다. 저널 쪽에는 기동 실패 배선이 있고 토폴로지 쪽에는 없다. 패턴은 이미 존재한다.

(b) TopologyManagementMode 는 완전히 고아다. 자기 선언 + 테스트 4건이 전부이며, 이 타입의 필드·파라미터·설정 프로퍼티가 저장소에 하나도 없다. enum javadoc 은 "VALIDATE_ONLY in production, always" 라고 쓰지만 이 모드를 읽어 분기하는 코드가 없다.

(c) 운영자용 표면 전체가 프로덕션 소비자를 갖지 않는다. describeImpact(영향 요약), fellShortOfTheEstimate(추정치 미달), isFullyAccounted(후보 정산 누락), isResumption(재개 여부) — 이 리프가 "운영자가 결정을 내리기 위해 봐야 할 것" 으로 정의한 네 가지가 전부 테스트에서만 호출된다. 이것들을 렌더링하는 API·CLI·로그가 src/main 에 없다.

(d) DestructiveOperationGuard 의 분기 5·6 에 도달하는 테스트가 없다. guard.authorize 를 부르는 테스트는 DestructiveOperationGuardTest 의 5건뿐이고, 그 5건이 각각 분기 1~4와 통과 경로를 덮는다. 두 에러 코드를 단언하는 테스트는 저장소 전체에 없다.

특히 눈에 띄는 것은, 유일하게 불일치 조합을 실제로 넘기는 테스트가 앞 분기에서 먼저 걸린다는 점이다.

// DestructiveOperationGuardTest.java:24-25, 50-59
private static final VerifiedApproval VALID =
    verified(DestructiveOperation.REPLAY, ORDERS, NOW.minusSeconds(60), NOW.plusSeconds(3600));

void anApplicationRuntimeCannotRedrive() {
  DestructiveOperationGuard guard = new DestructiveOperationGuard(false);   // 자격증명 없음
  assertThatThrownBy(() -> guard.authorize(
          DestructiveOperation.REDRIVE, ORDERS, Optional.of(VALID), false, NOW))
      .hasMessageContaining("admin credential");
}

REDRIVE 요청에 REPLAY 승인을 넘기므로 분기 5의 조건은 참이지만, 분기 2가 먼저 던진다. 테스트 이름이 겨냥한 것도 자격증명이므로 테스트 자체는 옳다 — 다만 이 조합이 존재하는 탓에 "불일치가 검사된다" 는 인상이 생긴다.

(e) 프로덕션에서는 분기 3~6이 도달 불가다. 스타터가 new DestructiveOperationGuard(false) 로 고정하므로 부팅된 애플리케이션에서는 항상 분기 2에서 멈춘다. 이는 의도된 설계이며 주석이 명시한다("An operator tool overrides this bean with true."). 즉 분기 3~6은 운영자 도구가 존재할 때만 쓰이는 코드이고, 그 운영자 도구는 이 저장소에 없다.

12.2 Conditional sibling comparison

대조군 1 — 같은 스타터의 두 검사. MessagingAdminDurabilityValidator(저널) vs 토폴로지 검증. 전자는 InitializingBean 으로 기동을 실제로 막고, 후자는 타입에 "기동을 막는다" 고 쓰여 있으나 호출부가 없다. 같은 팀, 같은 파일, 같은 위험 서술, 다른 결과.

대조군 2 — 검사의 이중화. verifyApproved*Plan 생성자 → guard.authorize 세 곳이 operation/source/expiry 를 겹쳐 본다. ApprovalVerifier javadoc 은 "a signature check covers them all at once" 라고 하므로 뒤의 두 곳은 원리상 잉여다. 그러나 ApprovedReplayPlan:24-25 의 주석이 이유를 밝힌다: "Checking it here means the mismatch cannot survive to the execute call under any code path." — 서명 검증을 거치지 않고 VerifiedApproval 을 얻는 경로가 생기더라도 계획 결합은 유지된다는 심층 방어다.

대조군 3 — messaging-testkit 과의 관점 공유. DestinationTopology 의 "manifest 를 자기 자신과 비교해 성공을 보고하는 것을 타입으로 막는다" 는 messaging-testkitBrokerFailureMatrix 가 사후에 고쳐야 했던 결함과 같은 것이다. 여기서는 처음부터 예방했고, 저기서는 발생한 뒤 고쳤다.

12.3 Duplicate mechanism sweep

(a) 인가 검사 3중화. 위 대조군 2. 의도된 심층 방어이며 주석 근거가 있다. 다만 대가가 있다: APPROVAL_OPERATION_MISMATCH 같은 코드가 세 파일에 각각 문자열로 존재하고, 세 곳의 메시지 문구가 서로 다르다. 상수 하나로 모으는 편이 검색·집계에 낫다.

(b) 정규 형식 인코딩 2종. (EVD-304)

// ApprovalGrant.canonicalForm()  — 길이 접두
canonical.append(value.length()).append(':').append(value);
//   javadoc: "A delimiter can be smuggled into a ticket or an identity to make two different
//             grants render identically; a length prefix cannot."

// ReplayPlan.digest() / RedrivePlan.digest()  — 구분자 결합
PlanDigest.ofCanonical(String.join("|", "REPLAY", replayId, destination, , topologyVersion, ));

계획 다이제스트 입력 중 | 를 담을 수 있는 필드는 topologyVersion 하나뿐이다 — DestinationName[a-z0-9][a-z0-9.-]{0,159} 로 막혀 있고 나머지는 UUID·불리언·Instant·10진수다. topologyVersionisBlank() 만 검사하며 BrokerTopologyInspector.topologyVersion()(애플리케이션이 구현하는 SPI)에서 온다.

현재 충돌은 만들 수 없다. 자유 형식 필드가 하나뿐이고 그 앞뒤 필드가 | 를 담을 수 없으므로 인코딩이 단사다. 기록하는 이유는 두 가지다: (1) 바로 옆 파일이 정확히 이 위험을 이유로 다른 방식을 쓰고 그 이유를 남겼다, (2) 단사성이 "자유 필드가 하나뿐" 이라는 조건에 의존하며 그 조건이 깨졌을 때의 결과가 "한 승인이 다른 계획을 인가" — PlanDigest 가 존재하는 이유 자체의 무력화다.

(c) 토폴로지 비교 2종. TopologyManifest.differencesFrom(...)List<String> 을, CompositeTopologyValidator(admin-runtime)는 TopologyIssue 를 만든다. 전자에는 severity 개념이 없다. 두 경로가 같은 판단을 서로 다른 표현으로 내리며, differencesFrom 의 프로덕션 호출부는 TopologyValidationRuntime:48 한 곳이다.

12.4 Documentation / measured-count drift

(a) "Four conditions" vs 실제 여섯. DestructiveOperationGuard 클래스 javadoc(:12-15)이 세는 조건은 넷이고 코드의 분기는 여섯이다. 빠진 둘(operation·source 불일치)은 인라인 주석으로 의도가 설명되어 있으므로 누락은 서술 쪽이다. 그리고 그 둘이 §12.1(d)의 미검증 분기와 정확히 같다.

(b) messaging-policy 의존이 import 0건.

git grep -n "import dev.caskeleton.messaging.policy" -- src/messaging/messaging-admin-api
(출력 없음)

allowed_dependenciesbuild.gradle 이 선언하지만 쓰이지 않는다. messaging-spring-cloud-stream-bridge, messaging-kafka-share-experimental, messaging-testkit 에 이어 네 번째 사례다.

(c) TopologyManagementMode 의 javadoc 이 강제 주체 없는 규칙을 서술한다. "VALIDATE_ONLY in production, always" 와 requireSafeFor(boolean production) 가 있으나, 이 enum 을 읽는 프로덕션 코드가 없으므로 규칙을 적용할 지점이 없다.

(d) 테스트 파일 수 대비 타입 수. production 24 : test 1. §10 참조.


13. Git/설계 문서에서 확인한 변화와 실패 기록

messaging-testkit 과 마찬가지로 이 리프도 javadoc 이 커밋 로그를 대신한다. 여섯 개의 "이전에는 이랬다" 기록이 있고, 전부 같은 결함 계열을 가리킨다: 자기 자신을 근거로 삼는 주장.

위치 기록된 과거 결함
VerifiedApproval.java:9-13 "…a plain AdminApproval record with a public constructor, so 'this plan was approved' was a claim the caller made about itself."
ApprovalGrant.java:11-14 "AdminApproval carried a ticket, an approver, and a window. Nothing in it said which operation… the audit trail recorded a ticket that proved nothing about what was executed."
PlanDigest.java:12-16 "The operator who got a redrive of one dead-letter destination approved could execute a redrive of a different one with the same ticket, and every audit record would look correct."
AdminOperationJournal.java:10-14 "…a ConcurrentHashMap registered by the starter as the default… both are worse than having no store at all because the map made the platform look protected."
AdminOperationState.java:5-9 "…recorded one fact — 'this approval was claimed' — and recorded it before any work happened."
DestructiveOperationGuardTest.java:30-32 "The guard used to take a plain AdminApproval record, which any caller could construct."
MessagingAdminDurabilityValidator.java:14-18 (스타터) "The previous default was an in-memory map registered by this starter, and nothing in the application said so."

일곱 개가 하나의 이야기다: 승인이 처음에는 데이터였고, 지금은 타입이다.

커밋 로그 자체는 정보가 없다.

a24ece9c feat: web, websocket 어댑터 추가 구현
01372634 refactor: 각 어댑터터별 리펙토링 진행
2f5d2fc2 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
d646c2f1 feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가

14. 런타임·터미널 Evidence

ID 파일 내용
EVD-302 evidence/raw/302-admin-api-topology-guarantee-unwired.txt requireAcceptable/validateTopology 프로덕션 호출 0건, 스타터 빈 전수, TopologyManagementMode 미사용, 운영자 표면 테스트 전용
EVD-303 evidence/raw/303-destructive-guard-untested-branches.txt guard 6분기 중 5·6 미검증, 불일치 조합이 앞 분기에 걸리는 정황, HmacApprovalVerifier src/main 생성 0건
EVD-304 evidence/raw/304-canonical-form-asymmetry.txt 길이 접두 vs | 결합, 필드별 | 포함 가능성 조사, 현재 단사성 판정
EVD-305 evidence/raw/305-messaging-admin-api-test-lane.txt 테스트 레인 9건 통과, 하류 리프 검증 분포

15. 명시적 설계 이유와 추론을 구분한 정리

코드/주석에 명시된 것

  • 승인을 타입으로 만든 이유 — 호출자가 자기 자신에 대해 주장하던 것을 검증 증거로 바꾼다 (VerifiedApproval.java:9-16).
  • 승인된 계획을 별도 타입으로 만든 이유 — 컴파일러가 인가를 강제한다 (ApprovedReplayPlan.java:10-13).
  • 4-eyes 를 record 생성자에 둔 이유 — 건너뛸 수 없는 자리 (ApprovalGrant.java:51-52).
  • 길이 접두 정규 형식의 이유 — 구분자 밀반입 방지 (ApprovalGrant.java:64-65).
  • 대칭키를 고른 이유와 그 전제 (HmacApprovalVerifier.java:15-20).
  • sign 을 같은 클래스에 둔 이유 — 정규 형식 합의가 두 곳으로 갈라지지 않도록 (:50-53).
  • 상수시간 비교의 이유 (:22-24).
  • 다이제스트를 먼저 검사하는 이유 — 진짜 승인의 계획 간 재사용이 막으려는 공격 (:72-73).
  • guard 를 중앙화한 이유 — 새 작업 추가가 enum 상수 추가로 끝나도록 (DestructiveOperationGuard.java:17-18).
  • dry run 을 항상 허용하는 이유 — 계획을 공짜로 만들어야 계획한다 (:14-15).
  • operation 검사가 필요한 이유 (:73-74).
  • 저널 키를 (ticket, digest) 로 잡은 이유 (AdminOperationRecord.java:10-12).
  • 리스가 resumeFrom 을 나르는 이유 (AdminOperationLease.java:8-11).
  • 상태 셋으로 나눈 이유 (AdminOperationState.java:5-9).
  • isDurable() 을 선언값으로 둔 이유 (AdminOperationJournal.java:87-89).
  • 토폴로지 선언/실측을 다른 타입으로 둔 이유 (DestinationTopology.java:9-11).
  • severity 를 finding 에 붙인 이유 (TopologyIssue.java:8-12).
  • 전부 모아 보고하는 이유 (TopologyValidationReport.java:10-12).
  • 프로덕션 자동 생성 금지의 이유 (TopologyManagementMode.java:8-12).
  • loopAcknowledged 를 분리한 이유 (ApprovedRedrivePlan.java:11-14).
  • 토폴로지를 두 번 보는 이유 (ApprovedReplayPlan.java:77-78).
  • redriveIdMessageId 와 분리한 이유 (RedriveRequest.java:10-13).
  • stillParked != candidates - moved 인 이유 (RedriveResult.java:10-13).
  • VerifiedApproval.toString() 을 줄인 이유 (:83-85).
  • 스타터가 false 를 고정하고 DestructiveMessagingAdmin 을 배선하지 않는 이유 (MessagingAdminAutoConfiguration.java:17-24, 38-39).

추론 (근거는 있으나 문서에 없음)

  • canonicalForm()"v1" 접두는 형식 버전 관리용으로 보인다. 명시된 문장은 없고, 버전을 읽어 분기하는 코드도 없다.
  • 계획 다이제스트가 길이 접두를 쓰지 않은 것은 의도적 예외가 아니라 누락으로 보인다 — 같은 리프에 반대 규칙의 명시적 근거가 있기 때문. 확인할 근거는 없다.
  • messaging-policy 의존이 남아 있는 이유는 알 수 없다.
  • 토폴로지 검증이 기동에 배선되지 않은 이유가 "운영자 도구 전용" 스탠스의 연장인지, 누락인지 판단할 근거가 코드에 없다. 스타터 javadoc 은 파괴적 작업에 대해서만 그 스탠스를 밝힌다.
  • 운영자 표면(describeImpact 등)의 소비자가 없는 것은 API·CLI 계층이 아직 없기 때문으로 보이나, 그 계획을 서술한 문서는 이 리프에 없다.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • production 24 + test 1 = 25개 Java 파일 전부 본문 확인.
  • 테스트 레인 9건 전건 통과 (EVD-305).
  • requireAcceptable·validateTopology 프로덕션 호출 0건, 스타터가 만드는 admin 빈 4종 전수 (EVD-302).
  • TopologyManagementMode 프로덕션 사용 0건 (EVD-302).
  • guard 6분기 중 5·6에 도달하는 테스트 부재, 그 이유까지 (EVD-303).
  • HmacApprovalVerifier src/main 생성 0건, ApprovalVerifier 구현체 1개 (EVD-303).
  • 계획 다이제스트 입력 필드별 | 포함 가능성 전수 조사와 현재 단사성 판정 (EVD-304).
  • module-info.java 저장소 전체 0개, 소유 모듈 밖에서 같은 패키지를 선언한 파일 0개.

확인하지 못한 것

  • JdbcAdminOperationJournalAdminOperationJournal javadoc 의 세 의무(공유·내구, (ticket,digest) 유일성, 단조 펜싱 토큰)를 실제로 지키는지 — 그 리프의 SSOT 이며 Postgres 컨테이너가 필요하다. 이 세션에서 실행하지 않았다.
  • messaging-admin-runtime 의 서비스들이 검사 순서를 올바르게 호출하는지 — 다음 문서에서 다룬다.
  • 부팅된 컨텍스트에서 app.messaging.admin.enabled=true 로 빈 그래프가 실제로 어떻게 되는지 — 런타임 관측 미수행.
  • 운영자 도구(adminCredentialPresent=true 를 등록하는 쪽)가 이 저장소 밖에 존재하는지.
  • topologyVersion 이 실제 배포에서 어떤 형식인지 — BrokerTopologyInspector 구현이 이 저장소에 없다.

17. 손볼 것

P2 — "BLOCKING 이면 기동이 실패한다" 는 보장이 어떤 배선에서도 실행되지 않는다

TopologyIssue.Severity.BLOCKING javadoc: "The destination cannot deliver a declared guarantee; startup must fail." TopologyValidationReport.requireAcceptable() javadoc: "Fails startup when any blocking issue was found." 그러나 requireAcceptable() 의 프로덕션 호출부는 0건이고, 그것을 부를 수 있는 유일한 진입점 validateTopology() 도 호출부가 0건이며, MessagingAdminService 빈은 스타터가 만들지 않는다(EVD-302).

결과: 복제 계수 1인 목적지에 durability 를 선언해도 컨텍스트는 정상 기동한다. 타입은 그 상황을 정확히 표현할 수 있고, 표현한 것을 아무도 읽지 않는다.

고치는 방법이 같은 파일에 이미 있다. MessagingAdminDurabilityValidatorInitializingBean 으로 저널 내구성을 기동 시점에 검사하고 실패시킨다. 같은 모양의 빈 하나 — CompositeTopologyValidator + 선언된 TopologyManifest 목록을 받아 afterPropertiesSet() 에서 validate(...).requireAcceptable() 을 호출 — 이면 된다. 단, @ConditionalOnBean(BrokerTopologyInspector.class) 는 유지해야 한다(inspector 없이 검증할 수 없으므로).

이 항목이 P2 인 이유: 이 리프는 runtime_memberships: ["app-bootstrap"] 이고, 보장이 문서·타입·테스트에 모두 존재하는데 배선만 없다. 읽는 사람은 보장이 있다고 믿을 근거가 세 겹으로 있다.

P2 — DestructiveOperationGuard 의 두 분기가 문서에도 없고 테스트에도 없다

operation 불일치(:72-83)와 source 불일치(:84-93)는 클래스 javadoc 의 "Four conditions" 에 포함되지 않고, 두 에러 코드를 단언하는 테스트도 저장소 전체에 없다(EVD-303).

이 둘은 사소한 검사가 아니다 — 5번 분기의 인라인 주석이 정확히 말한다: "A guard that only checks presence and window lets a verified redrive approval authorise a destination deletion." 즉 검증된 승인으로 목적지 삭제를 인가하는 것을 막는 검사다.

혼동을 키우는 정황이 하나 더 있다. anApplicationRuntimeCannotRedriveREDRIVE 요청에 REPLAY 승인을 넘기지만 adminCredentialPresent=false 라 분기 2에서 먼저 걸린다. 불일치 조합이 테스트에 등장하지만 그 분기는 실행되지 않는다.

수정: javadoc 을 여섯으로 고치고, new DestructiveOperationGuard(true) 위에서 operation 불일치·source 불일치 각각 1건씩 테스트를 추가한다. 이 리프에는 이미 verified(operation, source, from, until) 헬퍼가 있어 두 줄이면 된다.

P3 — 서명 능력과 검증 능력이 같은 객체에 있다

HmacApprovalVerifiersign()verify() 를 같은 키로 제공한다. javadoc 이 위험을 명시한다: "Holding this key is what makes a caller an issuer — it is not, and must not become, available to the runtime that executes operations."

그러나 대칭키에서는 검증하려면 서명할 수 있는 키를 가져야 한다. 승인을 검증하는 프로세스는 정의상 승인을 발급할 수 있고, 그 프로세스가 운영자 도구라면 "an operator cannot mint an approval for themselves" 는 성립하지 않는다. 이 리프의 테스트가 그 구조를 그대로 보여준다.

// DestructiveOperationGuardTest.java:20-22, 47
private static final HmacApprovalVerifier ISSUER = new HmacApprovalVerifier();

return ISSUER.verify(grant, ISSUER.sign(grant), digest, from);

같은 객체가 발급자이자 검증자이며, 변수 이름이 ISSUER 다.

현재 프로덕션 배선이 ApprovalVerifier 빈을 만들지 않으므로 지금 문제가 발생하지는 않는다(EVD-303). 그러나 운영자 도구가 등장하는 순간 이 구조가 활성화된다. 이 리프는 다른 모든 곳에서 "능력을 타입으로 표현" 하는데(VerifiedApproval 이 검증 사실을 증명하듯), 서명 능력만 타입으로 분리되어 있지 않다.

두 가지 방향이 있고 둘 다 javadoc 이 이미 열어 두었다. (1) ApprovalIssuer 를 별도 타입으로 분리해 "누가 서명 능력을 쥐는가" 를 타입에 드러낸다 — 클래스는 나뉘고 정규 형식은 ApprovalGrant.canonicalForm() 하나로 유지되므로 javadoc 이 우려한 "두 번째 구현이 드리프트한다" 는 발생하지 않는다. (2) javadoc 이 이미 언급한 대로 공개키 검증자를 구현해 검증 측이 서명 키를 갖지 않게 한다.

P3 — 계획 다이제스트가 승인 정규 형식과 다른 인코딩을 쓴다

ApprovalGrant.canonicalForm() 은 길이 접두를, ReplayPlan.digest()/RedrivePlan.digest()String.join("|", …) 를 쓴다(§12.3(b), EVD-304).

현재는 충돌을 만들 수 없다 — 자유 형식 필드가 topologyVersion 하나뿐이기 때문이다. 그러나 그 조건은 코드 어디에도 적혀 있지 않고, 필드가 하나 추가되면 조용히 깨진다. ApprovalGrantappendFieldPlanDigest 쪽으로 옮겨 재사용하는 편이 낫다 — 규칙과 그 근거가 이미 같은 리프에 있다.

부수적으로 topologyVersion 에 형식 제약을 주는 것도 검토할 만하다. 지금은 isBlank() 만 본다.

P3 — TopologyManagementMode 가 어디에도 연결되어 있지 않다

자기 선언과 테스트 4건이 전부다. 이 enum 을 읽는 프로덕션 코드도, 이것으로 매핑되는 설정 프로퍼티도 없다(EVD-302). 두 선택지가 있다: 실제로 배선하거나(선언된 토폴로지 관리 모드를 설정에서 읽고 requireSafeFor(isProduction) 를 기동 시 호출), 제거한다. 지금 상태는 "규칙이 코드에 있다" 는 인상만 준다.

P3 — 운영자용 표면 전체에 프로덕션 소비자가 없다

ReplayPlan.describeImpact, RedrivePlan.describeImpact, ReplayResult.fellShortOfTheEstimate, RedriveResult.isFullyAccounted, AdminOperationLease.isResumption — 다섯 개가 전부 테스트에서만 호출된다(EVD-302).

이것들은 잉여 코드가 아니라 아직 소비자가 없는 잘 설계된 표면이다. describeImpact 의 javadoc 이 "operator-facing" 이라고 쓰고 ApprovedPlanExecutionTest.aReplayIntoTheLiveGroupSaysSoInCapitals 가 대문자 LIVE 까지 검증한다. 문제는 그 문자열이 도달할 화면이 없다는 것이다. admin API·CLI 계층을 만들 때 이 다섯이 그 계층의 명세라는 점을 문서에 남겨 두는 것이 낫다.

P3 — VerifiedApproval 의 위조 방지가 package-private 에만 의존한다

이 저장소는 JPMS 를 쓰지 않는다(module-info.java 0개). 따라서 어떤 모듈이든 package dev.caskeleton.messaging.admin; 을 선언하면 VerifiedApproval.of(grant) 를 호출할 수 있다. 현재 그런 파일은 없지만, 이 타입의 존재 이유가 "아무도 만들 수 없다" 이므로 그 조건을 자동으로 지키는 검사가 있어야 한다.

ApprovalForgeryTest.aVerifiedApprovalCannotBeConstructedOutsideTheVerifier 가 있으나, 그것은 같은 패키지 안에서 API 표면을 확인하는 테스트지 다른 모듈의 패키지 선언을 막지 못한다. ArchUnit 규칙 한 줄 — "dev.caskeleton.messaging.admin 패키지는 messaging-admin-api 소스 경로에만 존재한다" — 이면 된다.

P3 — messaging-policy 의존이 import 0건이다

선언만 남아 있다. 제거 후보.

P3 — 같은 인가 실패 코드가 세 파일에 문자열 리터럴로 흩어져 있다

APPROVAL_OPERATION_MISMATCH, APPROVAL_SOURCE_MISMATCH, APPROVAL_PLAN_MISMATCH, APPROVAL_EXPIREDHmacApprovalVerifier, DestructiveOperationGuard, ApprovedReplayPlan, ApprovedRedrivePlan 에 각각 리터럴로 존재하며 메시지 문구가 서로 다르다. 검사의 3중화 자체는 의도된 심층 방어지만(§12.2 대조군 2), 코드 문자열은 상수 하나로 모으는 편이 집계와 검색에 낫다.

확인된 설계(문제 아님)
  • VerifiedApproval 의 unforgeable-token 패턴. private 생성자 + package-private 팩토리 + 검증자만 호출. 타입을 쥔 것이 검증 증거다.
  • Approved*Plan 을 별도 타입으로 만든 것. 인가를 컴파일러가 강제한다.
  • 4-eyes 를 record 생성자에 둔 것. 서명 대상 객체의 존재가 곧 통과 증거다.
  • 길이 접두 정규 형식. 구분자 밀반입을 원천 차단.
  • 상수시간 HMAC 비교 + 256비트 키 하한 + 키 방어적 복사.
  • 다이제스트를 서명에 묶은 것. 승인이 "기간" 이 아니라 "계획" 에 대한 것이 된다.
  • 검사의 3중화. ApprovedReplayPlan:24-25 가 이유를 밝힌다 — 어떤 코드 경로로도 불일치가 execute 까지 살아남지 못한다.
  • (approvalTicket, planDigest) 복합 키. "이미 실행됨" 과 "다른 계획에 재사용" 을 구분한다.
  • 리스가 resumeFrom 을 나르는 것. 재시도가 새 실행이 되지 않는다.
  • 펜싱 토큰 하한을 타입으로 강제.
  • isDurable() 선언값 + 스타터의 기동 실패 검사. 이 리프에서 실제로 배선까지 완료된 유일한 안전 장치다.
  • 선언 토폴로지와 실측 토폴로지를 다른 타입으로 둔 것. 자기 자신과 비교해 성공을 보고하는 것을 타입으로 막는다.
  • BLOCKING 을 첫 발견에서 던지지 않고 전부 모아 보고하는 것.
  • dry run 무조건 허용. 계획을 공짜로 만들어 계획을 유도한다.
  • loopAcknowledged 분리. 900건 승인과 400건 재실패 승인은 다른 결정이다.
  • redriveIdMessageId 분리. 리드라이브 루프를 일반 트래픽과 구별 가능하게 한다.
  • stillParked 를 뺄셈으로 계산하지 않는 것. DLQ-confirm-before-settle 규칙이 리드라이브에도 적용된다.
  • dry run 이 0을 넘기면 생성자가 거절. 결과 타입이 스스로를 검증한다.
  • VerifiedApproval.toString() 축약. 로그에 승인자를 다시 쓰지 않는다.
  • 인가 실패(MessageAuthorizationException)와 프로그래밍 오류(IllegalArgumentException)의 예외 타입 분리.

Source anchors

src/messaging/messaging-admin-api/build.gradle:1-6
src/config/architecture/modules.json                    (messaging-admin-api 항목)

main/…/AdminApproval.java:6-17,21-33,35-44
main/…/ApprovalGrant.java:8-28,39-59,61-87,89-97
main/…/ApprovalVerifier.java:5-13,16-28
main/…/HmacApprovalVerifier.java:12-25,28-45,47-61,63-100,102-110
main/…/VerifiedApproval.java:6-17,20-32,34-69,71-87
main/…/PlanDigest.java:9-19,22-28,30-46
main/…/DestructiveOperation.java:3-25
main/…/DestructiveOperationGuard.java:9-19,22-31,33-94
main/…/ReplayRequest.java:9-18,27-35
main/…/ReplayPlan.java:6-23,31-40,42-64,66-78
main/…/ApprovedReplayPlan.java:7-16,19-52,54-85
main/…/ReplayResult.java:7-21,30-39,41-48
main/…/RedriveRequest.java:7-20,24,26-36
main/…/RedrivePlan.java:6-19,27-40,42-62,64-71,73-85
main/…/ApprovedRedrivePlan.java:8-19,23-57,59-94
main/…/RedriveResult.java:7-21,25-38,40-50
main/…/AdminOperationJournal.java:7-19,22-41,43-54,56-63,65-73,75-82,84-92
main/…/AdminOperationRecord.java:7-25,39-62
main/…/AdminOperationLease.java:5-18,27-45,47-54
main/…/AdminOperationState.java:3-21
main/…/TopologyManifest.java:7-19,27-42,44-75
main/…/DestinationTopology.java:6-18,26-35,37-45
main/…/TopologyIssue.java:5-19,23-29,31-37,39-65,67-75
main/…/TopologyValidationReport.java:7-16,19-25,27-35,37-57,59-66,68-82
main/…/TopologyManagementMode.java:5-13,14-20,22-36
test/…/DestructiveOperationGuardTest.java:17-25,27-48,50-110,112-125,127-146

src/messaging/messaging-core-api/.../destination/DestinationName.java:5-23
src/messaging/messaging-spring-boot-starter/.../MessagingAdminAutoConfiguration.java:14-86
src/messaging/messaging-spring-boot-starter/.../MessagingAdminDurabilityValidator.java:11-67
src/messaging/messaging-admin-runtime/.../DefaultMessagingAdminService.java:106,117,127,169,178
src/messaging/messaging-admin-runtime/.../MessagingAdminService.java:32
src/messaging/messaging-admin-runtime/.../CompositeTopologyValidator.java:19-50
src/messaging/messaging-admin-runtime/.../TopologyValidationRuntime.java:48
src/messaging/messaging-admin-runtime/.../RedriveService.java:97
src/messaging/messaging-admin-runtime/.../ReplayService.java:59
src/messaging/messaging-admin-runtime/src/test/.../ApprovalForgeryTest.java:51,69,84,96,110,133,146,158,172,192
src/messaging/messaging-admin-runtime/src/test/.../ApprovedPlanExecutionTest.java:121-221

A19-MESSAGING-ADMIN-RUNTIME. messaging-admin-runtime

분석 중에는 messaging/MESSAGING-ADMIN-RUNTIME.md 파일이었다. 1,020줄.

messaging-admin-runtime 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-admin-runtime SSOT owner: messaging-admin-runtime integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-admin-runtime
  • canonical state analysisFile: §A19-MESSAGING-ADMIN-RUNTIME
  • source path: src/messaging/messaging-admin-runtime
  • registry allowed_dependencies: ["messaging-core-api", "messaging-policy", "messaging-admin-api", "messaging-transport-spi", "messaging-security", "messaging-observability"]
  • registry runtime_memberships: ["app-bootstrap"] — 배포된다
숫자
항목
production Java 파일 12
test Java 파일 6
전체 LOC 2,304 (main 1,313 / test 991)
패키지 1 (dev.caskeleton.messaging.admin.runtime)
test 메서드(실행 확인) 51 (EVD-309)
선언된 의존 project 6 (전부 api)
실제 import되는 의존 project 3 — policy·transport-spi·security 는 0건 (§12.4)
leaf 밖에서 import 하는 파일 3 (starter 2 + kafka IT 1)

12개 production 타입:

타입 종류 역할 src/main 생성
MessagingAdminService interface 비파괴 admin 표면 (5메서드)
DefaultMessagingAdminService class 257줄 유일한 오케스트레이터 0
DestructiveMessagingAdmin interface purge/delete/offset-reset 구현 0
ReplayService class 리플레이 실행 0
RedriveService class 210줄 리드라이브 실행 0 (test 1)
ReplayReport / RedriveReport record 실행 1회 결과
BrokerTopologyInspector interface 브로커 토폴로지 읽기 SPI
CompositeTopologyValidator class 매니페스트 전건 비교 (Stack A) 1 (스타터)
TopologyValidator class 1건 비교 + severity 판정 1 (내부 필드)
TopologyValidationRuntime class 두 번째 토폴로지 스택 (Stack B) 0
InMemoryAdminOperationJournal class 237줄 단일 프로세스 저널 1 (스타터)
Coverage ledger
scope/file group count disposition reason
src/main/java/** (12) 12 FULL_READ 전 파일 본문 확인
src/test/java/** (6) 6 FULL_READ 51개 테스트 본문·대역 구현 확인
build.gradle 1 FULL_READ 10줄
하류/상류 (starter, outbox-jdbc, admin-api) STRUCTURAL_ONLY 도달성·계약 대조에 필요한 범위. SSOT 는 각 리프 소유
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

messaging-admin-api 가 정의한 타입들을 실제로 실행하는 계층이다. 계획을 세우고, 저널에 자리를 잡고, 옮기고, 결과를 보고한다.

구조는 세 층이다.

  1. 오케스트레이션MessagingAdminService / DefaultMessagingAdminService. 계획·승인·저널·실행을 잇는다.
  2. 실행ReplayService, RedriveService. 각각 하나의 작업을 수행하며, 브로커 접촉은 SPI(ReplayExecutor, RedriveSource, RedrivePublisher)로 밀어낸다.
  3. 토폴로지·저널CompositeTopologyValidator+TopologyValidator, TopologyValidationRuntime, InMemoryAdminOperationJournal.

경계 밖: 브로커 클라이언트가 없다. Kafka·Rabbit 어느 것도 import 하지 않고, 모든 브로커 접촉이 함수형 인터페이스 뒤에 있다. Spring 도 없다 — 배선은 전부 starter 몫이다.

읽고 나서 남는 인상은 두 가지로 갈린다. 개별 부품은 대단히 정교하다 — 저널의 펜싱 프로토콜, 리드라이브 루프의 per-item 경계, 토폴로지 severity 판정은 각각 실패 사례를 겪고 나온 코드로 보이며 그 근거가 주석에 있다. 반면 부품을 잇는 층은 실행된 적이 없다 — §12.1 에서 보듯 DefaultMessagingAdminService 는 프로덕션에서도 테스트에서도 인스턴스화되지 않는다.


2. 의존성과 런타임 배선

// messaging-admin-runtime/build.gradle 전문
apply plugin: 'java-library'

dependencies {
    api project(':messaging:messaging-core-api')
    api project(':messaging:messaging-policy')
    api project(':messaging:messaging-admin-api')
    api project(':messaging:messaging-transport-spi')
    api project(':messaging:messaging-security')
    api project(':messaging:messaging-observability')
}

실측 import (EVD-308):

선언 패키지 import 판정
messaging-admin-api …messaging.admin 45 O
messaging-core-api …messaging.api 6 O
messaging-observability …messaging.observation 1 O
messaging-policy …messaging.policy 0 X
messaging-transport-spi …messaging.transport 0 X
messaging-security …messaging.security 0 X

6개 중 3개가 미사용이다. 지금까지 본 리프 중 가장 많다.

배선은 starter 한 곳뿐이고, 이 리프에서 빈이 되는 것은 이다(EVD-307).

// MessagingAdminAutoConfiguration.java  (@ConditionalOnProperty app.messaging.admin.enabled=true)
:57  return new InMemoryAdminOperationJournal();
:84  return new CompositeTopologyValidator(inspector);   // @ConditionalOnBean(BrokerTopologyInspector)

MessagingAdminService, ReplayService, RedriveService, DestructiveMessagingAdmin — 넷 다 빈이 없다. starter 는 그중 하나에 대해서만 이유를 밝힌다.

// MessagingAdminAutoConfiguration.java:22-24
 * <p>{@link DestructiveMessagingAdmin} is deliberately absent from this class. No bean for it is
 * ever auto-configured: an operator tool that needs purge or delete registers one itself, with an
 * admin credential this runtime does not hold.

나머지 셋의 부재에 대한 설명은 어디에도 없다.


3. 패키지/컴포넌트 지도

단일 패키지. 의존 방향이 한 곳에서 어긋난다.

   MessagingAdminService (interface)
        ^
        |  implements
   DefaultMessagingAdminService ──────┐
        |                             |
        |  uses                       |  uses
        v                             v
   ReplayService                 RedriveService
        |                             |
        |  ReplayExecutor             |  RedriveSource / RedrivePublisher
        v                             v
     [브로커 — 이 리프 밖]        [DLQ — 이 리프 밖]

   ReplayService.audit : RedriveService.AuditSink     <-- 형제의 중첩 타입에 의존 (§12.3(b))

토폴로지 쪽은 두 개의 완전히 분리된 스택이 나란히 있다(§12.3(a)).

  Stack A:  BrokerTopologyInspector -> CompositeTopologyValidator -> TopologyValidator
                -> List<TopologyIssue> (severity) -> TopologyValidationReport.requireAcceptable()
                -> MessagingConfigurationException("TOPOLOGY_MISMATCH")

  Stack B:  TopologyValidationRuntime.TopologyReader -> TopologyValidationRuntime.validate(...)
                -> TopologyManifest.differencesFrom() -> List<String>
                -> MessageTopologyException("TOPOLOGY_MISMATCH")   (직접 throw)

4. 계약·불변식·상태 모델

4.1 DefaultMessagingAdminService — 검사 순서가 요점이다
// DefaultMessagingAdminService.java:22-42
/**
 * Wires plan, approval, and execution together for the non-destructive admin operations.
 *
 * <p>Execution runs four checks, in this order, and the order is the point.
 *
 * <ol>
 *   <li>The approval is still inside its window.
 *   <li>The topology has not changed since the plan was approved.
 *   <li>The approval has not already been executed.
 *   <li>Only then does anything move.
 * </ol>
 *
 * <p>The journal entry is written <em>before</em> the work rather than after it. Writing it
 * afterwards leaves a window where a second execution starts while the first is still running,
 * which is precisely the double-redrive the journal exists to prevent.
 *
 * <p>Writing it first used to have a cost the previous store never paid: an operation that died
 * halfway had consumed its approval and left no record of how far it got. The journal keeps a
 * checkpoint and hands back a lease that says where to resume, so a retry continues the same
 * operation instead of either redoing it or requiring a new approval.
 */

코드가 그 순서를 지킨다.

// executeRedrive, :174-203  (executeReplay 도 동형)
plan.requireExecutable(now, inspector.topologyVersion());          // 검사 1·2
AdminOperationLease lease = journal.begin();                       // 검사 3
Instant startedAt = clock.get();
try {
  report = redriveService.redrive(, lease.resumeFrom(), completed -> journal.checkpoint());
} catch (RuntimeException failure) {
  journal.fail(lease, lease.resumeFrom(), failureCodeOf(failure), clock.get());
  throw failure;
}
journal.complete(lease, report.moved() + report.failed(), clock.get());

실패 경로의 근거도 있다.

// :143-148
} catch (RuntimeException failure) {
  // The operation stays resumable rather than silently consuming the approval: the journal
  // entry moves to FAILED at its checkpoint, and a retry takes it over from there.

저널에 들어가는 실패 코드는 정제된다.

// :214-227
/**
 * <p>The journal is read by operators during incidents and its contents outlive the process. A
 * raw exception message can carry a destination, a payload fragment, or a credential from a
 * driver's own error text, so only the platform's own code or the exception's simple name goes in.
 */
private static String failureCodeOf(RuntimeException failure) {
  if (failure instanceof MessagingException messaging) { return messaging.failure().code(); }
  return failure.getClass().getSimpleName();
}

리스 길이 선택에도 근거가 붙어 있다.

// :45-51
/**
 * <p>Long enough that a slow batch does not lose its lease mid-flight, short enough that a dead
 * replica does not park an approval for an hour.
 */
private static final Duration LEASE_DURATION = Duration.ofMinutes(5);

리플레이와 리드라이브의 비대칭이 하나 있다. 리드라이브는 lease.resumeFrom() 과 체크포인트 콜백을 실행 측에 넘기지만, 리플레이는 넘기지 않는다.

// :140-142  executeReplay
report = replayService.replay(request, Optional.of(plan.approval()), plan.approval().approvedBy(), now);

ReplayService.replay(...) 시그니처에 resumeFrom 이 없다(ReplayService.java:53-54). 즉 리플레이는 리스를 받지만 재개하지 않는다 — 죽으면 처음부터 다시 읽는다. 클래스 javadoc 의 "a retry continues the same operation instead of either redoing it" 은 리드라이브에만 해당한다.

4.2 RedriveService — per-item 경계와 finally 감사

세 가지 실패를 고쳤다고 javadoc 이 적는다.

// RedriveService.java:67-84
/**
 * <p>Three things were wrong with running this as a plain loop. A synchronous failure from the
 * publisher — a broker that refuses the connection rather than the message — propagated out of
 * the loop, so the remaining candidates were never attempted and the audit record was never
 * written: the operation left no trace of the items it had already moved. A retry then started
 * from the first candidate and republished them. And nothing bounded how many times one message
 * could be redriven.
 */

세 수정이 코드에 있다.

// :142-156  (1) 한 건의 예외는 한 건의 실패지 패스 전체의 실패가 아니다
private boolean attempt(MessageId messageId, RedriveRequest request) {
  try { result = publisher.republish(); }
  catch (RuntimeException failure) {
    // One message that cannot be republished is a failed item, not a failed pass. Letting it
    // propagate abandoned every candidate behind it.
    return false;
  }
  if (result.completion() != PublishCompletion.CONFIRMED) { return false; }
  source.settle(request.source(), messageId);       // 확인된 것만 정산
  return true;
}

// :121-137  (2) 감사 기록은 finally 에서
} finally {
  // In the finally block on purpose: an operation that dies partway must still leave a record
  // of what it moved, because that record is what the resumed attempt and the incident review
  // both read.
  audit.record(new MessagingAuditEvent("REDRIVE", subject, ));
}

발행 → 확인 → 정산 순서가 이 리프의 핵심 불변식이다.

// :19-21
/**
 * <p>A redrive is a publish followed by a settlement, in that order, exactly like dead lettering in
 * reverse. A message whose republish did not confirm stays in the dead letter destination: losing
 * it on the way back would be the one outcome worse than leaving it parked.
 */

세 번째 수정 — 재개 — 는 인덱스 계산이 틀렸다. §12.1(a)에서 상술한다.

4.3 ReplayService — 안전한 형태를 공짜로 만든다
// ReplayService.java:13-22
/**
 * <p>An isolated replay reads alongside the live consumer and needs no approval, because it changes
 * nothing: a throwaway group has its own offsets. Replaying into an existing production group is a
 * different operation entirely — it rewinds a live consumer and reprocesses everything since — so
 * it goes through the destructive guard.
 *
 * <p>Making the safe form free and the destructive form approved is what keeps operators from
 * reaching for the destructive one out of convenience.
 */

판단은 옳다. 구현이 그 판단을 dryRun 파라미터로 표현한다.

// :58-64
boolean needsApproval = !request.isolatedConsumerGroup();
guard.authorize(
    DestructiveOperation.REPLAY,
    request.destination(),
    approval,
    request.dryRun() || !needsApproval,     // <- guard 의 dryRun 인자
    now);

DestructiveOperationGuard.authorizedryRun 이 참이면 즉시 반환한다(DestructiveOperationGuard.java:54-56). 즉 격리 리플레이는 "승인 불필요" 가 아니라 "dry run 인 척" 으로 통과한다. 감사 이벤트는 그 구분을 남긴다 — approval.map(VerifiedApproval::ticket).orElse("isolated")(:77) — 그러나 guard 쪽에는 남지 않는다. §17 P3.

4.4 InMemoryAdminOperationJournal — 프로토콜이 단순화되지 않았다
// InMemoryAdminOperationJournal.java:17-27
/**
 * A single-process journal, for tests and for local development.
 *
 * <p>It reports {@link #isDurable()} as false, and the starter refuses to run a production profile
 * on a journal that says so. That declaration is the point of this class existing at all: the
 * previous in-memory store was registered as the production default and nothing distinguished it
 * from a shared one, so the gap was invisible until two replicas executed the same approval.
 *
 * <p>The semantics are otherwise the real ones — uniqueness on {@code (ticket, digest)}, lease
 * takeover with a monotonic token, resume from checkpoint — so a test that passes here is testing
 * the protocol rather than a simplification of it.
 */

마지막 문장이 지켜지는지가 이 클래스의 값어치다. 확인 결과 지켜진다.

beginclaim(...) 이 네 갈래다(:64-115).

기존 상태 처리 코드
없음 새 record, token=1, itemsCompleted=0 :72-85
COMPLETED APPROVAL_ALREADY_EXECUTED — "an approval authorises one execution, not a standing permission" :86-92
STARTED + 리스 유효 ADMIN_OPERATION_IN_FLIGHT — "two runtimes executing one approval is a duplicate storm, not a faster redrive" :93-100
FAILED 또는 리스 만료 체크포인트 유지, token+1 로 인수 :101-114

펜스는 update(...) 에 있다.

// :206-214
if (current.leaseToken() != lease.leaseToken()) {
  // The fence. A stalled runtime that wakes up and writes here would otherwise overwrite
  // the progress of whichever replica took the operation over.
  throw new MessageAuthorizationException("ADMIN_OPERATION_LEASE_LOST", );
}

그리고 키 생성이 ApprovalGrant.canonicalForm() 의 규칙을 그대로 가져온다.

// :219-225
private static String key(String approvalTicket, PlanDigest planDigest) {
  // Length-prefixed for the same reason the grant's canonical form is: a ticket containing the
  // separator must not be able to collide with a different ticket and digest pair.
  return String.join("", Integer.toString(approvalTicket.length()), ":", approvalTicket,
      planDigest.value());
}

길이 접두 규칙이 messaging-admin-api 밖으로 전파된 사례다. (그 규칙이 닿지 않은 유일한 곳이 계획 다이제스트라는 점은 §A19-MESSAGING-ADMIN-API §12.3(b)에 있다.)

checkpoint/complete/fail 셋 다 Math.max(current.itemsCompleted(), itemsCompleted) 로 clamp 한다(:128, :147, :167). 이것이 DefaultMessagingAdminServicejournal.fail(lease, lease.resumeFrom(), …)낡은 값을 넘겨도 진행이 되돌아가지 않는 이유다. §12.4(c).

4.5 TopologyValidator — severity 가 판단이다
// TopologyValidator.java:11-22
/**
 * <p>Which discrepancies block is a judgement encoded here rather than left to configuration.
 * Replication factor and absence are blocking because a destination that is missing or unreplicated
 * cannot deliver the durability its profile promises. A partition count that is <em>higher</em>
 * than declared is advisory rather than blocking: extra partitions do not break durability, and
 * someone scaling a topic up deliberately should not be met with a refusal to start.
 *
 * <p>A partition count that is <em>lower</em> is blocking, because it silently reduces the
 * concurrency the destination was sized for and, on a keyed topic, changes which key lands where.
 */
조건 severity 코드
목적지 부재 BLOCKING (그리고 즉시 반환) :39-43
physicalName 불일치 BLOCKING :45-49
파티션 < 선언 BLOCKING :51-57
파티션 > 선언 ADVISORY :58-66
복제 계수 < 선언 BLOCKING :68-75
필수 설정 불일치/부재 BLOCKING ("unset") :77-87

부재 시 즉시 반환하는 것도 옳다 — 없는 목적지의 파티션 수를 보고할 이유가 없다.

4.6 DestructiveMessagingAdmin — 분리가 곧 통제
// DestructiveMessagingAdmin.java:10-20
/**
 * The operations that destroy data an application cannot recreate.
 *
 * <p>A separate interface from {@link MessagingAdminService}, and no bean for it is ever registered
 * in an application runtime. The separation is the control: an application that never receives this
 * type cannot purge a topic even if every other guard is bypassed, because the method does not
 * exist on anything it holds.
 *
 * <p>Each operation takes an {@link Approved} argument rather than an approval parameter, so the
 * authorisation cannot be forgotten at a call site — there is no way to call these without one.
 */

첫 문단의 논리는 견고하다. 두 번째 문단이 문제다 — Approved 가 담는 것은 VerifiedApproval 이 아니라 평범한 AdminApproval 이다. §17 P2.


5. 주요 실행 경로

경로 A — 리드라이브 (설계상 의도된 흐름)

DefaultMessagingAdminService.executeRedrive(ApprovedRedrivePlan)
  1) plan.requireExecutable(now, inspector.topologyVersion())
        승인 윈도우 / 승인 토폴로지 / 계획 토폴로지 / 루프 승인
  2) journal.begin(ticket, digest, redriveId, leaseOwner, 5분, now)
        -> COMPLETED 면 거절, 유효 리스 있으면 거절, 아니면 token+1 로 인수
        -> AdminOperationLease(resumeFrom = 이전 체크포인트)
  3) redriveService.redrive(request, approval, subject, now, resumeFrom, checkpoint)
        guard.authorize(REDRIVE, source, approval, dryRun, now)
        candidates = source.peek(source, batchSize)
        for m in candidates.subList(resumeFrom, end):
            republish -> CONFIRMED 면 settle, 아니면 failed++
            completed++ ; checkpoint(completed) -> journal.checkpoint(...)
        finally: audit.record(...)
  4) journal.complete(lease, moved + failed, now)
  5) RedriveResult(candidates, moved, stillParked=failed, elapsed, dryRun)

경로 B — 토폴로지 검증

Stack A 는 validateTopology() 로 진입해 보고서를 돌려준다. 그 보고서로 requireAcceptable() 을 부르는 코드는 없다. Stack B 는 validate(...) 안에서 직접 던진다. 둘 다 프로덕션 진입점이 없다(EVD-307).

경로 C — 파괴적 작업

없다. DestructiveMessagingAdmin 구현체가 0건이므로 PURGE·OFFSET_RESET·DELETE_DESTINATION 은 이 저장소에 실행 경로가 없다.


6. 실패 경로와 복구/번역

상황 처리 위치
발행이 예외를 던짐 그 한 건만 실패 처리, 루프 계속 RedriveService:146-150
발행이 CONFIRMED 아님 실패 처리, 정산하지 않음 → DLQ 잔류 RedriveService:151-153
실행 중 예외 journal.fail(...) 후 재던짐 → 재개 가능 상태 DefaultMessagingAdminService:143-148
예외 메시지 코드 또는 클래스 단순명만 저널에 :222-227
승인 이미 소진 APPROVAL_ALREADY_EXECUTED InMemory…:86-92
다른 런타임이 실행 중 ADMIN_OPERATION_IN_FLIGHT :93-100
리스 상실 후 쓰기 ADMIN_OPERATION_LEASE_LOST :206-214
저널 항목 없음 ADMIN_OPERATION_NOT_JOURNALLED :201-205
토폴로지 불일치 (A) MessagingConfigurationException("TOPOLOGY_MISMATCH") TopologyValidationReport:78
토폴로지 불일치 (B) MessageTopologyException("TOPOLOGY_MISMATCH") TopologyValidationRuntime:54

마지막 두 줄이 §12.3(a)의 요약이다 — 같은 코드 문자열, 다른 예외 타입, 다른 판정 규칙.

attempt(...) 가 모든 RuntimeException 을 삼키는 것은 근거가 있지만 대가도 있다: 실패 사유가 어디에도 남지 않는다. 감사 이벤트는 failed 개수만 담고(:135), 어떤 메시지가 왜 실패했는지는 기록되지 않는다.


7. 트랜잭션·동시성·수명주기

트랜잭션 경계 없음 — InMemoryAdminOperationJournalConcurrentHashMap.compute(...) 로 키 단위 원자성을 얻는다(:44, :198). begin 의 검사-후-갱신 전체가 compute 람다 안에 있어 두 복제본이 동시에 begin 해도 하나만 성공한다. AdminOperationJournalTest.twoReplicasRacingProduceExactlyOneLease 가 그것을 검증한다.

펜싱 토큰은 세 지점에서 동작한다: 인수 시 existing.leaseToken() + 1(:110), 쓰기 시 토큰 대조(:206), 그리고 clamp 로 인한 단조성(:128, :147, :167). aRuntimeThatLostItsLeaseCannotWriteOverTheSuccessor 가 세 가지를 한 번에 확인한다 — 낡은 리스의 complete(30) 이 거절되고 기록은 45·STARTED 로 남는다.

RedriveService·ReplayService·DefaultMessagingAdminService 는 모두 불변 필드만 갖는다. clockSupplier<Instant> 로 주입받아 시간도 외부화되어 있다.

수명주기 훅 없음. 이 리프의 어떤 클래스도 InitializingBean·SmartLifecycle 을 구현하지 않는다 — 이것이 §17 첫 항목의 직접 원인이다.


8. 설정·기능 플래그·환경 차이

이 리프 자체에는 설정이 없다. 상수 하나가 코드에 고정되어 있다.

위치 근거
LEASE_DURATION = 5분 DefaultMessagingAdminService:51 javadoc :47-49
MAX_BATCH = 100 (admin-api RedriveRequest:24)

리스 5분은 프로퍼티가 아니다. 근거는 명시적이지만("느린 배치가 리스를 잃지 않을 만큼 길고, 죽은 복제본이 승인을 한 시간 묶어두지 않을 만큼 짧게"), 배치 크기·브로커 지연에 따라 달라질 값을 조정할 수단이 없다.


9. 퍼시스턴스/외부 시스템 세부

직접 접점 없음. 전부 SPI 뒤에 있다.

SPI 구현 (프로덕션) 구현 (테스트)
BrokerTopologyInspector 0 — 애플리케이션이 제공해야 함 TopologyValidatorTest:133 익명 1
ReplayService.ReplayExecutor 0 0
RedriveService.RedriveSource 0 RecordingSource 1
RedriveService.RedrivePublisher 0 람다 4
RedriveService.AuditSink 0 RecordingAudit 1
TopologyValidationRuntime.TopologyReader 0 람다 4
DefaultMessagingAdminService.ReplayEstimator 0 0
DefaultMessagingAdminService.RedriveEstimator 0 0 — 그리고 패키지 밖에서는 구현 불가 (§12.4(a))

여덟 개 SPI 전부 프로덕션 구현이 0이다. AdminOperationJournal 만이 예외로, JdbcAdminOperationJournal(outbox-jdbc-postgresql)과 InMemoryAdminOperationJournal 둘을 갖는다.


10. 테스트 레인과 실제 증명 범위

EVD-309: ./gradlew :messaging:messaging-admin-runtime:test --rerun-tasks51 tests, 0 failures, 0 skipped.

클래스 실제 겨냥 대상
TopologyValidatorTest 13 TopologyValidator(6) · TopologyValidationReport(2) · CompositeTopologyValidator(1) · TopologyManagementMode(3, admin-api 소유)
ApprovedPlanExecutionTest 11 전부 admin-api 타입 (Approved*Plan, *Result, *Plan.describeImpact)
ApprovalForgeryTest 10 전부 admin-api 타입 (VerifiedApproval, HmacApprovalVerifier, ApprovalGrant)
AdminOperationJournalTest 8 InMemoryAdminOperationJournal
RedriveResumptionTest 5 RedriveService
TopologyValidationRuntimeTest 4 TopologyValidationRuntime

51건 중 21건이 이 리프의 클래스를 거치지 않는다. ApprovalForgeryTestApprovedPlanExecutionTestmessaging-admin-api 의 타입을 직접 조립해 검증한다. 이는 admin-api 문서 §10에서 본 것의 반대쪽 면이다 — 그 리프의 불변식이 여기서 검증되고, 여기의 오케스트레이터는 검증되지 않는다.

증명되지 않는 것:

  • DefaultMessagingAdminService 257줄 — 인스턴스화하는 테스트 0건(EVD-307). 검사 순서, 저널 begin/checkpoint/fail 시퀀스, 실패 시 재던짐, failureCodeOf 정제 — 전부 미실행.
  • ReplayService 99줄 — 인스턴스화 0건. 격리 리플레이의 guard 우회, 감사 이벤트 구성, dry run 조기 반환 전부 미실행.
  • RedriveService 의 실패+재개 교집합(§12.1(a)).
  • Stack A 와 Stack B 의 파티션 스케일업 불일치 — 양쪽이 각자의 테스트에서 반대 결과를 내는데, 그 대비를 확인하는 테스트가 없다(§12.3(a)).

컨테이너 레인 없음. JdbcAdminOperationJournal 의 Postgres IT 는 다른 리프 소유이며 이 세션에서 실행하지 않았다.


11. 빌드/ArchUnit/CI 강제 지점

build.gradle 10줄. 이 리프 고유의 게이트는 없다. 루트 공통 게이트만 적용된다.

주목: RedriveEstimate 의 접근성 문제를 잡는 게이트가 없다. public 인터페이스가 package-private 타입을 반환하는 것은 Java 가 허용하고 Checkstyle·SpotBugs·ErrorProne 기본 설정 어느 것도 기본으로 잡지 않는다. ErrorProne 에 관련 검사가 있으나 활성화되어 있지 않다.


12. 실제 사용 여부와 negative-space probes

12.1 Public surface reachability

(a) [P1] 재개된 리드라이브가 옮기지 못한 메시지를 건너뛴다 (EVD-306)

resumeFrom 은 매 시도마다 새로 peek 한 목록의 인덱스로 쓰인다.

// RedriveService.java:100, 107-120
List<MessageId> candidates = source.peek(request.source(), request.batchSize());

int completed = resumeFrom;
for (MessageId messageId :
    candidates.subList(Math.min(resumeFrom, candidates.size()), candidates.size())) {
  if (attempt(messageId, request)) { moved.add(messageId); } else { failed++; }
  completed++;                       // 성공·실패 양쪽에서 증가
  checkpoint.accept(completed);
}

주석은 // Everything before resumeFrom was moved and settled by the previous attempt. 이라고 쓴다(:109-110). 그러나 completedmoved + failed 다. 실패분은 settle 되지 않아 DLQ 에 남고, 다음 peek 결과에 그대로 포함된다. 성공분만 사라진다.

구체적 시나리오:

DLQ = [m1, m2, m3, m4, m5]
1차: peek -> [m1..m5]
     m1 CONFIRMED -> settle (DLQ 에서 제거)   completed=1, checkpoint(1)
     m2 미확인    -> failed++ (DLQ 잔류)      completed=2, checkpoint(2)
     프로세스 사망.  저널 itemsCompleted = 2
2차: lease.resumeFrom = 2
     peek -> [m2, m3, m4, m5]        (m1 만 사라짐)
     subList(min(2,4), 4) = [m4, m5]
     -> m2(실패했던 것), m3(시도조차 안 된 것)을 영구히 건너뛴다
     m4, m5 성공.  RedriveReport(candidates=4, moved=2, failed=0)
     journal.complete(lease, 2, now)  -> COMPLETED, 승인 소진

운영자에게는 성공으로 보이고, m2·m3 는 DLQ 에 남으며, 어떤 기록도 그 둘을 지목하지 않는다. 승인이 소진되었으므로 재실행은 APPROVAL_ALREADY_EXECUTED 로 거절된다.

플랫폼은 이것을 감지할 술어를 이미 갖고 있다.

// messaging-admin-api/RedriveResult.java:40-50
/**
 * <p>An unaccounted message is a bug, not a partial success: it was neither republished nor left
 * parked, which means the redrive lost track of it.
 */
public boolean isFullyAccounted() { return moved + stillParked == candidates; }

위 시나리오는 2 + 0 == 4false. 정확히 이 결함을 잡는다. 그러나 isFullyAccounted() 의 프로덕션 호출부는 0건이다(EVD-302). 아무도 묻지 않는다.

(b) 오케스트레이션 계층이 어디에서도 생성되지 않는다 (EVD-307)

DefaultMessagingAdminService     src/main=0  src/test=0
ReplayService                    src/main=0  src/test=0
RedriveService                   src/main=0  src/test=1
TopologyValidationRuntime        src/main=0  src/test=4
CompositeTopologyValidator       src/main=1  src/test=1
InMemoryAdminOperationJournal    src/main=1  src/test=3

src/main 생성은 전 저장소에서 2건뿐이며 둘 다 starter 다(:57, :84).

DefaultMessagingAdminService 는 이 리프에서 가장 큰 클래스이고 "검사 순서가 요점" 이라고 스스로 말하는 클래스인데, 그 순서가 한 번도 실행된 적이 없다.

(c) DestructiveMessagingAdmin 은 구현체가 0건이다

git grep -n "DestructiveMessagingAdmin" -- src
  DestructiveMessagingAdmin.java:21           (선언)
  MessagingAdminService.java:21               ({@link} 참조)
  MessagingAdminAutoConfiguration.java:22     ({@link} 참조)
git grep -n "DestructiveMessagingAdmin.Approved|new Approved(|DestructiveResult" -- src
  (선언 파일 제외 후 출력 없음)

DestructiveOperation 5개 상수 중 PURGE·OFFSET_RESET·DELETE_DESTINATION 세 개는 이 저장소에 실행 경로가 없다. starter 가 그 부재를 의도로 설명하지만("an operator tool … registers one itself"), 그 도구는 이 저장소에 없다.

(d) 여덟 개 SPI 전부 프로덕션 구현 0건. §9 표.

12.2 Conditional sibling comparison

대조군 1 — 리플레이 vs 리드라이브의 재개. 리드라이브는 resumeFrom + 체크포인트 콜백을 받고, 리플레이는 받지 않는다(§4.1). 둘 다 같은 저널을 쓰고 같은 리스를 받는다. 리플레이가 재개되지 않는 이유를 설명하는 문장은 없다. 리플레이가 본질적으로 멱등(같은 구간을 다시 읽음)이라 재개가 불필요하다는 해석은 가능하나, 그렇다면 리스를 받는 이유가 설명되지 않는다.

대조군 2 — 두 개의 저널 구현. InMemoryAdminOperationJournal(Math.max)과 JdbcAdminOperationJournal(GREATEST)이 독립적으로 같은 clamp 를 구현했다. 인터페이스는 그것을 요구하지 않는다. §12.4(c).

대조군 3 — MessagingAuditSink vs RedriveService.AuditSink. 시그니처가 동일한 두 인터페이스. 전자는 "모든 기록이 MessagingRedactor 를 통과했다" 는 계약을 갖고, 후자는 갖지 않는다. §12.3(b).

12.3 Duplicate mechanism sweep

(a) 토폴로지 검증 스택 2벌 — 판정이 어긋난다 (EVD-307)

항목 Stack A (CompositeTopologyValidator+TopologyValidator) Stack B (TopologyValidationRuntime)
입력 SPI BrokerTopologyInspector TopologyReader
비교 로직 TopologyValidator.compare TopologyManifest.differencesFrom
결과 타입 List<TopologyIssue> (severity) List<String>
파티션 > 선언 ADVISORY — 기동 허용 차이 → 기동 거부
physicalName 검사 O (BLOCKING) X
부재 처리 BLOCKING issue "… does not exist" 문자열
실패 방식 보고서 반환 → requireAcceptable() validate(...) 안에서 직접 throw
예외 타입 MessagingConfigurationException MessageTopologyException
코드 문자열 TOPOLOGY_MISMATCH TOPOLOGY_MISMATCH
프로덕션 호출부 0 0

파티션 스케일업 판정이 정반대이며, 양쪽 다 자기 테스트에서 확인된다.

// TopologyValidatorTest.java:63-72  (Stack A)
void extraPartitionsAreAdvisoryBecauseScalingUpIsLegitimate() {
  List<TopologyIssue> issues = validator.compare(manifest(), observed(24, 3, ));  // 선언 12
  assertThat(issues).singleElement()
      .satisfies(issue -> assertThat(issue.severity()).isEqualTo(TopologyIssue.Severity.ADVISORY));
}
// TopologyValidatorTest.java:118-127
void anAdvisoryOnlyReportStillStarts() {
   assertThatCode(report::requireAcceptable).doesNotThrowAnyException();
}

Stack A 의 판단에는 근거가 명시되어 있다(TopologyValidator.java:59 — "Scaling a topic up is a legitimate operation; refusing to start would punish it"). Stack B 의 differencesFromactualPartitions != partitions 로 방향을 구분하지 않는다(TopologyManifest.java:57). TopologyValidationRuntimeTest 4건은 스케일업을 시도하지 않아 불일치가 드러나지 않는다.

(b) 감사 싱크 인터페이스 2벌 (EVD-308)

// messaging-observability/MessagingAuditSink.java:18-25
public interface MessagingAuditSink { void record(MessagingAuditEvent event); }

// RedriveService.java:199-209
public interface AuditSink { void record(observation.MessagingAuditEvent event); }

시그니처도 이벤트 타입도 같다. admin-runtime 은 이미 messaging-observability 를 의존하며 그 모듈에서 MessagingAuditEvent 를 import 한다(RedriveService:126). 즉 표준 싱크를 쓸 수 있는데 중첩 인터페이스를 새로 선언했다.

파생 결과 셋:

  • ReplayService 가 형제 서비스의 중첩 타입에 의존한다 — private final RedriveService.AuditSink audit;(ReplayService:28).
  • MessagingAuditSinkInMemory 구현을 제공하는데(:33-55), RedriveResumptionTestRecordingAudit 를 다시 만든다(:203-210).
  • 계약이 하나 유실된다. MessagingAuditSink javadoc: "Every record has already passed MessagingRedactor, so an audit trail proves who did what without becoming a second copy of the payload." RedriveService.AuditSink 에는 그런 서술이 없고, RedriveService:125-136 은 목적지 이름과 details 를 레닥션 없이 넣는다.

(c) TopologyValidator 인스턴스가 CompositeTopologyValidatorprivate final 필드로 고정되어 있다.

// CompositeTopologyValidator.java:22
private final TopologyValidator validator = new TopologyValidator();

주입이 아니라 생성이다. TopologyValidator 가 상태 없는 순수 비교기이므로 실질 문제는 없으나, severity 판정을 교체하려면 이 클래스를 고쳐야 한다 — "which discrepancies block is a judgement encoded here rather than left to configuration"(TopologyValidator:14)와 일관된 선택이다.

12.4 Documentation / measured-count drift

(a) public 인터페이스가 패키지 밖에서 구현 불가능하다 (EVD-308)

// DefaultMessagingAdminService.java:242-256
record RedriveEstimate(int candidates, int alreadyRedriven) {}      // 수식어 없음 = package-private

@FunctionalInterface
public interface RedriveEstimator {                                  // public
  RedriveEstimate estimate(RedriveRequest request);                  // package-private 반환 타입
}

생성자는 이것을 외부에서 받는다 — public DefaultMessagingAdminService(…, RedriveEstimator, …)(:78-88). 그러나 RedriveEstimator 를 구현하려면 RedriveEstimate 를 이름으로 써야 하고, 그 타입은 패키지 밖에서 접근할 수 없다. 컴파일은 통과한다.

대조: 같은 파일의 ReplayEstimatorlong 을 반환하므로 외부 구현이 가능하다.

현재 드러나지 않는 이유는 §12.1(b) 다 — 이 생성자를 부르는 코드가 없다.

(b) 선언된 의존 6개 중 3개가 import 0건. messaging-policy, messaging-transport-spi, messaging-security. §2 표.

(c) 저널의 단조성이 인터페이스 계약에 없다 (EVD-308)

AdminOperationJournal javadoc 은 구현 의무 셋을 명시한다 — "shared and durable", "uniqueness on (approvalTicket, planDigest)", "leases with a monotonic fencing token". itemsCompleted 의 단조성은 그 목록에 없다. fail@param 은 오히려 반대로 읽힌다: "how many items are durably done".

그런데 유일한 호출자가 낡은 값을 넘긴다.

// DefaultMessagingAdminService.java:146, :200
journal.fail(lease, lease.resumeFrom(), failureCodeOf(failure), clock.get());

lease.resumeFrom()이번 시도가 시작될 때의 값이다. 이번 시도의 체크포인트로 올라간 값이 아니다. 진행이 되돌아가지 않는 것은 두 구현이 각각 clamp 하기 때문이다.

// InMemoryAdminOperationJournal.java:128, 147, 167
Math.max(current.itemsCompleted(), itemsCompleted)
// JdbcAdminOperationJournal CHECKPOINT / SETTLE SQL
SET items_completed = GREATEST(items_completed, ?)

파라미터를 문자 그대로 저장하는 세 번째 구현은 이 호출자와 결합했을 때 체크포인트를 잃는다. AdminOperationJournalTest.aCheckpointNeverMovesBackwards 가 in-memory 구현에 대해 이 성질을 검증하지만, 그것은 구현 테스트지 계약이 아니다.

(d) DestructiveMessagingAdmin.ApprovedVerifiedApproval 이 아니라 AdminApproval 을 담는다. §17 P2.


13. Git/설계 문서에서 확인한 변화와 실패 기록

이 리프도 javadoc 이 이력을 대신한다. 다섯 개의 "이전에는 이랬다" 가 있고 전부 분산 실행의 실패를 가리킨다.

위치 기록된 과거 결함
DefaultMessagingAdminService:34-41 "The journal entry is written before the work rather than after it. Writing it afterwards leaves a window where a second execution starts while the first is still running…"
RedriveService:70-75 "A synchronous failure from the publisher … propagated straight out, so every candidate behind it was abandoned and the audit record was never written. … A retry then started from the first candidate and republished them."
InMemoryAdminOperationJournal:20-23 "the previous in-memory store was registered as the production default and nothing distinguished it from a shared one, so the gap was invisible until two replicas executed the same approval."
AdminOperationJournalTest:19-22 "recorded a single fact — 'this approval was claimed' — before any work happened, in a map. An operation that died halfway had spent its approval…"
RedriveResumptionTest:33-36 "The loop had no per-item boundary. … The operation left no trace of what it had already moved, and a retry started again from the first candidate and republished it."

다섯이 하나의 이야기다: 크래시와 복제본을 고려하지 않은 admin 평면. 고친 결과가 리스·펜싱·체크포인트·per-item 경계다.

그리고 마지막 두 항목이 §12.1(a)와 이어진다 — "재시도가 처음부터 다시 시작하는" 문제를 고치려고 resumeFrom 을 도입했고, 도입한 지점의 인덱스 계산이 실패분을 고려하지 않았다.

커밋 로그는 정보가 없다(4개, messaging 전체 공통).


14. 런타임·터미널 Evidence

ID 파일 내용
EVD-306 evidence/raw/306-redrive-resume-skips-unmoved.txt 재개 인덱스 결함, 구체적 시나리오, 테스트 대역이 불변식을 재현하지 못하는 지점
EVD-307 evidence/raw/307-admin-runtime-two-topology-stacks.txt 토폴로지 스택 2벌 대조표, 조립 탐침 전수, DestructiveMessagingAdmin 구현 0건
EVD-308 evidence/raw/308-admin-runtime-api-and-dependency-defects.txt RedriveEstimate 접근성, 감사 싱크 중복, 미사용 의존 3건, 저널 단조성 계약 부재, Approved 의 승인 타입
EVD-309 evidence/raw/309-messaging-admin-runtime-test-lane.txt 51건 통과 + 커버리지 분포

15. 명시적 설계 이유와 추론을 구분한 정리

코드/주석에 명시된 것

  • 검사 순서와 그 이유 (DefaultMessagingAdminService:25-32).
  • 저널을 작업 전에 쓰는 이유 (:34-37).
  • 리스가 재개 지점을 나르는 이유 (:38-41).
  • 리스 5분의 상하한 근거 (:47-49).
  • 실패 시 재개 가능 상태로 남기는 이유 (:144-145).
  • 실패 코드를 정제하는 이유 — 저널은 사건 중 운영자가 읽고 프로세스보다 오래 산다 (:216-220).
  • 한 건의 발행 예외가 패스 전체를 죽이면 안 되는 이유 (RedriveService:147-148).
  • 감사 기록을 finally 에 두는 이유 (:122-124).
  • 발행→확인→정산 순서의 이유 (:19-21).
  • 리드라이브 id·카운터가 메시지와 함께 이동하는 이유 (:23-25).
  • 격리 리플레이를 무료로 두는 이유 (ReplayService:16-22).
  • in-memory 저널이 isDurable()==false 를 선언하는 이유와 그 존재 이유 (InMemoryAdminOperationJournal:20-23).
  • 프로토콜을 단순화하지 않은 이유 (:25-27).
  • 리스 인수 시 토큰을 올리는 이유 = 펜스 (:101-102, :207-208).
  • 저널 키를 길이 접두로 만든 이유 (:221-222).
  • severity 판정을 코드에 두는 이유, 그리고 각 판정의 근거 (TopologyValidator:14-22, :59).
  • 전부 모아 보고하는 이유 (CompositeTopologyValidator:14-17).
  • BrokerTopologyInspector 가 읽기 전용인 이유 (:9-12).
  • 파괴적 작업을 별도 인터페이스로 분리한 이유 (DestructiveMessagingAdmin:13-16).
  • 토폴로지 버전이 파싱되지 않는 불투명 값인 이유 (BrokerTopologyInspector:27-28).

추론 (근거는 있으나 문서에 없음)

  • 리플레이가 재개되지 않는 이유. 리플레이가 멱등이라 불필요하다는 해석이 자연스러우나, 그렇다면 리스를 받는 이유가 설명되지 않는다.
  • RedriveService.AuditSinkMessagingAuditSink 대신 선언한 이유. 의존 순서 문제로 보이지는 않는다 — 이미 그 모듈을 의존한다.
  • TopologyValidationRuntime(Stack B)이 남아 있는 이유. Stack A 가 나중 것으로 보이나(severity·physicalName 검사가 추가되었으므로), 그 판단을 뒷받침할 커밋 이력이 없다.
  • policy·transport-spi·security 의존이 남아 있는 이유.
  • RedriveEstimate 가 package-private 인 것이 의도인지 누락인지.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • production 12 + test 6 = 18개 Java 파일 전부 본문 확인.
  • 테스트 레인 51건 전건 통과, 클래스별 분포 (EVD-309).
  • 재개 인덱스 결함과 테스트 대역이 그것을 재현할 수 없는 이유 (EVD-306).
  • 조립 탐침 전수 — DefaultMessagingAdminService·ReplayService 생성 0건 (EVD-307).
  • 토폴로지 두 스택의 판정 대조표, 양쪽 테스트가 반대 결과를 확인한다는 사실 (EVD-307).
  • DestructiveMessagingAdmin 구현 0건, Approved/DestructiveResult 사용 0건 (EVD-307).
  • RedriveEstimate 접근성, 감사 싱크 중복, 의존 3건 미사용, 저널 clamp 를 두 구현이 각각 갖는다는 사실 (EVD-308).

확인하지 못한 것

  • §12.1(a)의 시나리오를 실제로 재현하지 않았다. 결함은 코드와 테스트 대역을 읽어 도출했고, 실패+재개를 조합하는 테스트를 작성해 관찰하지는 않았다. (문서화 작업이 애플리케이션 소스를 수정하지 않는다는 제약 때문. 재현 테스트는 코드 변경 요청이 있을 때 작성하는 것이 맞다.)
  • JdbcAdminOperationJournal 의 실제 동작 — Postgres 컨테이너 필요, 미실행. SQL 문자열은 읽어서 GREATEST 를 확인했다.
  • 부팅된 컨텍스트에서 app.messaging.admin.enabled=true 일 때의 빈 그래프 — 런타임 관측 미수행.
  • BrokerTopologyInspector 의 실제 구현이 어떤 topologyVersion 문자열을 내는지 — 구현이 저장소에 없다.
  • Stack B 가 언제·왜 남았는지.

17. 손볼 것

P1 — 재개된 리드라이브가 옮기지 못한 메시지를 영구히 건너뛴다

resumeFrom 은 "시도한 개수"(moved + failed)인데, subList 로 건너뛰는 대상은 매번 새로 peek 한 목록이고 그 목록에서 사라진 것은 "성공한 것"뿐이다. 실패분과 미시도분이 앞쪽에 남아 있으므로, 건너뛰기는 정확히 그것들을 지운다(EVD-306).

결과: 리드라이브가 성공으로 보고되고, 승인이 소진되고, 일부 메시지가 DLQ 에 남으며, 어떤 기록도 그것들을 지목하지 않는다. 사건 복구 중에 실행되는 작업이라는 점이 심각도를 올린다.

고칠 방향은 두 가지다.

  1. 인덱스 대신 신원으로 재개한다. 저널이 개수가 아니라 이미 옮긴 MessageId 집합(또는 마지막 성공 위치의 브로커 오프셋)을 들고 있으면 목록이 줄어드는 것과 무관해진다. AdminOperationRecord 에 필드 추가가 필요하다.
  2. completedmoved.size() 로 바꾸고 실패분은 세지 않는다. 그러면 resumeFrom 이 "사라진 개수" 와 일치하므로 새 peek 의 인덱스로 유효해진다. 다만 실패분을 반복해서 재시도하게 되므로, 리드라이브 횟수 상한(javadoc :75 가 언급하는 "nothing bounded how many times one message could be redriven")이 함께 필요하다.

어느 쪽이든 RedriveService.RedriveSource 대역이 settlestaged 에서 제거하도록 고쳐야 회귀 테스트가 성립한다. 현재 대역은 실제 불변식을 재현하지 못한다.

// RedriveResumptionTest.java:192-200  — settle 이 staged 를 줄이지 않는다
@Override public List<MessageId> peek(DestinationName destination, int batchSize) { return staged; }
@Override public void settle(DestinationName destination, MessageId messageId) { settled.add(messageId); }

그리고 RedriveResult.isFullyAccounted() 를 실제로 호출하는 곳을 만들어야 한다. 이 결함을 잡는 술어가 이미 존재하는데 프로덕션 호출부가 0건이다(EVD-302). DefaultMessagingAdminService.executeRedrive 가 결과를 만든 직후 확인하고, 불일치면 저널에 FAILED 로 남기는 것이 자연스럽다.

P2 — 파괴적 작업의 승인만 위조 가능한 형태로 남아 있다
// DestructiveMessagingAdmin.java:23-38
record Approved(
    DestructiveOperation operation,
    DestinationName destination,
    AdminApproval approval,            // <- public 생성자를 가진 평범한 record
    long estimatedMessagesAffected) {  }

생성자는 null·음수만 본다. approval 이 이 operation 을 인가하는지, 이 destination 을 인가하는지, estimatedMessagesAffected 가 승인 상한 이하인지 — 아무것도 검사하지 않는다. 계획 다이제스트 필드 자체가 없다.

이 형태가 정확히 messaging-admin-api 가 고쳤다고 기록한 것이다.

// messaging-admin-api/VerifiedApproval.java:9-13
 * <p>The approved-plan types used to hold a plain {@code AdminApproval} record with a public
 * constructor, so "this plan was approved" was a claim the caller made about itself. Any code that
 * could reach the execute method could write {@code new AdminApproval("TICKET-1", "someone", now,
 * later)} and the platform believed it.

수정은 REPLAY·REDRIVE(복구 가능한 작업)에 적용되었고, PURGE·DELETE_DESTINATION·OFFSET_RESET(복구 불가능한 작업)에는 적용되지 않았다.

현재 구현체가 0건이라 실행되는 결함은 아니다(EVD-307). 그러나 이 인터페이스는 운영자 도구가 구현하라고 존재하는 것이고, 그 도구가 생기는 순간의 모양이 이것이다. ApprovedApprovedReplayPlan 과 같은 형태로 — VerifiedApproval + 생성자 검사 — 바꾸는 것이 맞다.

P2 — 토폴로지 검증 스택이 두 벌이고 판정이 어긋난다

Stack A 는 파티션 스케일업을 ADVISORY 로 두어 기동을 허용하고 그 근거를 명시한다. Stack B 는 같은 상황을 차이로 보고 기동을 거부한다. 둘 다 프로덕션 호출부가 0건이라 지금은 충돌하지 않지만, §A19-MESSAGING-ADMIN-API §17 첫 항목대로 토폴로지 검증을 기동에 배선하는 순간 어느 스택을 배선하느냐가 스케일업한 배포의 기동 여부를 가른다.

Stack A 가 남아야 할 것으로 보인다 — severity 구분, physicalName 검사, 근거 주석이 있고 테스트도 13건으로 더 두껍다. Stack B(TopologyValidationRuntime, TopologyReader, ObservedTopology, 그리고 그것만 쓰는 TopologyManifest.differencesFrom)를 제거하는 편이 낫다.

같은 코드 문자열 TOPOLOGY_MISMATCH 를 두 예외 타입이 쓰는 것도 정리 대상이다.

P2 — 오케스트레이터가 어디에서도 실행되지 않는다

DefaultMessagingAdminService 257줄과 ReplayService 99줄이 프로덕션에서도 테스트에서도 인스턴스화되지 않는다(EVD-307). 검사 순서·저널 시퀀스·실패 시 재던짐·실패 코드 정제가 전부 미검증이다.

DefaultMessagingAdminService 의 생성자는 10개 인자를 받고 그중 8개가 SPI 또는 Supplier 이므로, 대역으로 조립하는 테스트를 쓰는 비용은 낮다. §12.1(a)의 회귀 테스트도 이 층에서 쓰는 것이 자연스럽다 — 저널·리스·리드라이브 루프가 함께 도는 것이 결함이 나타나는 조건이기 때문이다.

P3 — public 인터페이스를 패키지 밖에서 구현할 수 없다

RedriveEstimator(public)의 반환 타입 RedriveEstimate 가 package-private 이다(EVD-308). DefaultMessagingAdminService 의 public 생성자가 그 인터페이스를 요구하므로, 외부 조립이 불가능하다.

RedriveEstimate 를 public 으로 올리는 것이 최소 수정이다. 더 나은 방향은 DefaultMessagingAdminService 밖의 최상위 record 로 꺼내는 것 — 지금은 오케스트레이터의 내부 타입이 SPI 계약의 일부가 되어 있다.

P3 — 감사 싱크가 중복 선언되어 있고 레닥션 계약이 유실된다

RedriveService.AuditSinkMessagingAuditSink 와 시그니처가 같다. admin-runtime 은 이미 messaging-observability 를 의존한다. 표준 싱크를 쓰면 세 가지가 함께 해결된다: ReplayService 가 형제의 중첩 타입에 의존하는 것, InMemory 구현 재작성, 그리고 무엇보다 "모든 기록이 MessagingRedactor 를 통과했다" 는 계약.

현재 RedriveService:125-136 은 목적지 이름과 details 를 그대로 넣는다. 목적지 이름은 DestinationName 이라 형식이 제한되어 있어 지금은 문제가 아니지만, 계약이 없는 자리에 값이 늘어나는 것을 막을 것이 없다.

P3 — 저널의 itemsCompleted 단조성이 인터페이스 계약에 없다

AdminOperationJournal javadoc 은 구현 의무 셋을 명시하면서 이것을 빠뜨렸고, fail@param 은 오히려 문자 그대로 저장하라고 읽힌다. 유일한 호출자는 낡은 값을 넘긴다. 두 구현이 각각 clamp 해서 무사한 상태다(EVD-308).

두 가지 중 하나가 필요하다. 인터페이스 javadoc 에 "itemsCompleted 는 단조 증가해야 하며 구현은 기존 값보다 작은 값을 무시한다" 를 명시하거나, 호출자가 실제 체크포인트 값을 넘기도록 고친다. 후자가 더 정직하다 — 지금 journal.fail(lease, lease.resumeFrom(), …) 은 "이번 시도가 아무것도 못 했다" 고 주장하는 것이고, 그것은 대개 사실이 아니다.

P3 — 리플레이가 리스를 받지만 재개하지 않는다

executeReplayjournal.begin(...) 으로 리스를 받고 lease.resumeFrom() 을 쓰지 않는다. ReplayService.replay(...) 시그니처에 재개 지점이 없고 체크포인트 콜백도 없다. 클래스 javadoc 의 "a retry continues the same operation" 은 리드라이브에만 해당한다.

리플레이가 재개 불필요하다면(같은 구간을 다시 읽는 것이 멱등이므로) 그 근거를 적고, 저널 사용을 "중복 실행 방지" 로만 한정하는 것이 낫다. 재개가 필요하다면 리드라이브와 같은 형태로 맞춘다.

P3 — 격리 리플레이의 guard 우회가 dryRun 파라미터로 표현된다
// ReplayService.java:58-64
guard.authorize(REPLAY, request.destination(), approval, request.dryRun() || !needsApproval, now);

판단 자체는 근거가 있다. 다만 "승인이 필요 없다" 와 "실제로는 아무것도 하지 않는다" 가 guard 입장에서 구별되지 않는다. DestructiveOperationGuardskipAuthorization 성격의 별도 경로를 두거나, 격리 리플레이는 애초에 guard 를 거치지 않는 편이 의도를 드러낸다.

P3 — 선언된 의존 6개 중 3개가 import 0건

messaging-policy, messaging-transport-spi, messaging-security. 제거 후보.

P3 — 실패한 리드라이브 항목의 사유가 어디에도 남지 않는다

attempt(...) 는 예외와 미확인을 모두 false 로 접는다(RedriveService:142-156). 감사 이벤트는 failed 개수만 담는다(:135). 사건 복구 중에 "왜 이 메시지들이 안 갔는가" 를 물을 수 있어야 하는데 답이 없다. RedriveReport 에 실패 사유별 집계(코드 → 개수) 정도만 추가해도 크게 달라진다.

확인된 설계(문제 아님)
  • 저널을 작업 전에 쓰고, 리스·펜싱 토큰·체크포인트로 분산 실행을 통제하는 프로토콜. begin 의 네 갈래, update 의 토큰 대조, 인수 시 토큰 증가가 전부 근거와 함께 있고 테스트 8건이 확인한다.
  • compute(...) 로 검사-후-갱신을 원자화한 것. 두 복제본 경쟁이 정확히 하나의 리스를 낳는다.
  • 저널 키의 길이 접두. ApprovalGrant.canonicalForm() 의 규칙을 명시적으로 인용해 가져왔다.
  • 실패 코드 정제. 저널이 사건 중에 읽히고 프로세스보다 오래 산다는 이유가 명시적이다.
  • 리드라이브 루프의 per-item 경계. 한 건의 예외가 뒤의 후보를 버리지 않는다.
  • 감사 기록을 finally 에 둔 것. 중단된 작업도 흔적을 남긴다.
  • 발행→확인→정산 순서. 미확인 메시지는 DLQ 에 남는다 — "돌아오는 길에 잃는 것이 주차된 채로 두는 것보다 나쁘다".
  • 격리 리플레이를 무료로 둔 것. 안전한 형태를 편하게 만들어 파괴적 형태로 손이 가지 않게 한다.
  • isDurable() 선언 + starter 의 기동 거부. 이 리프에서 배선까지 완료된 유일한 안전 장치.
  • in-memory 저널이 프로토콜을 단순화하지 않은 것. 여기서 통과한 테스트가 프로토콜을 검증한다는 주장이 실제로 성립한다.
  • 토폴로지 severity 판정을 설정이 아니라 코드에 둔 것, 그리고 각 판정에 근거를 붙인 것.
  • 부재 시 즉시 반환. 없는 목적지의 파티션 수를 보고하지 않는다.
  • 파괴적 작업을 별도 인터페이스로 분리하고 빈을 만들지 않는 것. 타입을 받지 못한 코드는 메서드 자체가 없다.
  • BrokerTopologyInspector 를 읽기 전용으로 둔 것.
  • topologyVersion 을 파싱하지 않는 불투명 값으로 규정한 것.
  • 시간을 Supplier<Instant> 로 외부화한 것.

Source anchors

src/messaging/messaging-admin-runtime/build.gradle:1-10
src/config/architecture/modules.json                    (messaging-admin-runtime 항목)

main/…/MessagingAdminService.java:13-24,25-65
main/…/DefaultMessagingAdminService.java:22-42,45-51,53-62,64-103,105-108,110-119,121-158,160-170,172-212,214-227,229-240,242-256
main/…/DestructiveMessagingAdmin.java:10-20,23-38,40-57,59-81
main/…/ReplayService.java:13-23,26-42,44-85,87-98
main/…/RedriveService.java:16-26,29-51,53-65,67-91,92-140,142-156,158-179,181-197,199-209
main/…/ReplayReport.java:6-20
main/…/RedriveReport.java:3-17
main/…/BrokerTopologyInspector.java:6-13,16-22,24-32
main/…/CompositeTopologyValidator.java:11-18,21-22,24-31,33-51
main/…/TopologyValidator.java:11-22,25-90
main/…/TopologyValidationRuntime.java:10-17,20-29,31-58,60-71,73-87
main/…/InMemoryAdminOperationJournal.java:17-28,33-62,64-115,117-135,137-154,156-174,176-179,181-184,186-193,195-217,219-225,227-236

test/…/AdminOperationJournalTest.java:16-23,34-57,59-90,92-112,114-125,127-136,138-144
test/…/RedriveResumptionTest.java:30-37,51-73,75-91,93-113,115-125,127-137,139-158,183-201,203-210
test/…/TopologyValidatorTest.java:22-30,32-104,106-127,129-152,154-171
test/…/TopologyValidationRuntimeTest.java:14-16,18-64
test/…/ApprovalForgeryTest.java:51,69,84,96,110,133,146,158,172,192,219
test/…/ApprovedPlanExecutionTest.java:39,121-221

src/messaging/messaging-admin-api/.../AdminOperationJournal.java:7-19,43-54,65-73
src/messaging/messaging-admin-api/.../RedriveResult.java:40-50
src/messaging/messaging-admin-api/.../TopologyManifest.java:44-75
src/messaging/messaging-admin-api/.../VerifiedApproval.java:9-13
src/messaging/messaging-admin-api/.../DestructiveOperationGuard.java:54-56
src/messaging/messaging-observability/.../MessagingAuditSink.java:7-17,18-25,33-55
src/messaging/messaging-spring-boot-starter/.../MessagingAdminAutoConfiguration.java:14-24,37-41,54-58,80-85
src/messaging/messaging-outbox-jdbc-postgresql/.../JdbcAdminOperationJournal.java:73-89,217-245

A19-MESSAGING-CLAIM-CHECK. messaging-claim-check

분석 중에는 messaging/MESSAGING-CLAIM-CHECK.md 파일이었다. 581줄.

messaging-claim-check 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-claim-check SSOT owner: messaging-claim-check integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-claim-check
  • canonical state analysisFile: §A19-MESSAGING-CLAIM-CHECK
  • source path: src/messaging/messaging-claim-check
  • registry allowed_dependencies: ["messaging-core-api", "messaging-reliability-api"]
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 6
production LOC 418
패키지 1 (dev.caskeleton.messaging.claimcheck)
test 파일 3
test 메서드(실행 확인) 22
외부(비프로젝트) 의존성 0

여섯 타입:

타입 종류 역할 leaf 밖 참조
ClaimCheckStore interface payload 저장·조회·삭제 port 0
ClaimCheckPolicy record 문턱과 보존 규칙 0
ClaimCheckPublisher class 발행 측 오프로드 결정 0
ClaimCheckResolver class 소비 측 조회 + 검증 0
ClaimCheckIntegrityGuard class digest·크기·만료 검사 0
ClaimCheckIntegrityException exception digest 불일치 0

여섯 전부 leaf 밖 참조가 0이다.

Coverage ledger
scope/file group count disposition reason
src/main/java/** (6) 6 FULL_READ 전 파일 본문 확인
src/test/java/** (3) 3 FULL_READ 테스트명·fake 구현 확인
build.gradle 1 FULL_READ 6줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

Claim Check 패턴 — 브로커 한계를 넘는 payload를 객체 저장소에 두고 메시지는 참조만 나른다.

messaging-reliability-apiClaimCheckReference(storageKey·sizeBytes·sha256·expiresAt)를 값 타입으로 쓰고, 이 leaf가 그것을 만들고 검증하는 동작을 소유한다.

경계 진술이 두 클래스에 있다.

// ClaimCheckIntegrityGuard.java:14-17
 * <p>A claim check turns one message into two systems that can drift. The payload store has its own
 * retention, its own replication, and its own access control, and none of them are coordinated with
 * the broker's. So a consumer that fetches bytes and decodes them without checking is trusting
 * something the message never proved.
// ClaimCheckResolver.java:11-15
 * <p>Verification is not optional and cannot be skipped by a caller. An object store key is a
 * string, and a message carrying the wrong one  through a bug, a replay against a rotated bucket,
 * or a deliberate tamper  fetches bytes that decode perfectly into the wrong object. The digest is
 * the only thing standing between that and a handler acting on someone else's data.

**"decode perfectly into the wrong object"**가 이 leaf의 위협 모델이다 — 실패가 아니라 잘못된 성공.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), messaging-reliability-api(api).

나가는 것: messaging-spring-boot-starterallowed_dependencies에 포함된다.

배선: 없다. ClaimCheckStore의 production 구현이 0이고(유일한 구현은 테스트의 FakeStore), ClaimCheckPublisher·ClaimCheckResolver·ClaimCheckPolicy 생성이 leaf 밖에서 0건이다.

그런데 runtime_memberships["app-bootstrap"]이다. starter closure를 통해 배포 아티팩트에 실린다.

messaging-cloudevents와 같은 조합이다 — 싣고 쓰지 않는다(§A19-MESSAGING-CLOUDEVENTS §12.1).


3. 패키지/컴포넌트 지도

발행 측
  ClaimCheckPublisher(store, policy)
    └── offload(byte[]) → Offloaded(payload, Optional<ClaimCheckReference>)
          ├── policy.shouldOffload(len) == false → Offloaded(payload.clone(), empty)
          └── true → store.put(payload, retention) → Offloaded(new byte[0], reference)

소비 측
  ClaimCheckResolver(store)
    └── resolve(inline, Optional<reference>, now)
          ├── reference 없음 → inline.clone()
          ├── reference.isExpired(now) → CLAIM_CHECK_EXPIRED
          ├── store.get(reference) == null → CLAIM_CHECK_NOT_FOUND
          └── guard.verify(...) → 검증된 바이트
                └── *_MISMATCH → ClaimCheckIntegrityException으로 승격

정책
  ClaimCheckPolicy(thresholdBytes, retention, brokerRetention, maxRedeliveryWindow)
    └── 생성자가 retention >= brokerRetention + maxRedeliveryWindow를 강제

4. 계약·불변식·상태 모델

4.1 ClaimCheckPolicy — 보존이 생성자 불변식이다
Duration required = brokerRetention.plus(maxRedeliveryWindow);
if (retention.compareTo(required) < 0) {
  throw new MessagingConfigurationException("CLAIM_CHECK_RETENTION_TOO_SHORT", ...);
}

javadoc이 이유를 적는다.

// :10-14
 * <p>The retention rule is the one that matters. A claim check object deleted while its message is
 * still deliverable turns a large message into an undeliverable one  the consumer fetches, gets
 * nothing, and the message dead-letters for a reason that has nothing to do with the message. So
 * retention must exceed the broker's own retention plus the full retry and dead-letter window, and
 * the constructor refuses a configuration where it does not.

이것이 messaging-reliability-apiInboxRepository.purgeProcessedBefore javadoc이 요구하고 강제하지 않는 것과 같은 형태의 규칙인데, 이쪽은 생성자가 강제한다. 같은 저장소에서 같은 종류의 시간 관계 규칙을 한 곳은 강제하고 한 곳은 문서로만 둔다 — 그 leaf §17이 소유한다.

문턱과 목적지 payload 상한을 분리한 이유도 명시돼 있다.

// :16-18
 * <p>The threshold is separate from the destination's payload limit. Offloading starts well below
 * the limit, because the limit is where the broker refuses the message and the threshold is where
 * carrying it inline stops being a good idea.

DEFAULT_THRESHOLD_BYTES = 262,144 = 1 MiB의 1/4이고 javadoc이 그렇게 부른다.

defaults()가 브로커 1일 보존 + 1일 재시도 경로에 대해 3일 보존을 준다 — 요구치(2일)보다 1일 여유.

4.2 ClaimCheckPublisher — 순서와 미삭제
// :9-17
 * <p>The object is written <em>before</em> the message is published, and that order is the whole
 * design. Publishing first would let a consumer receive a reference to an object that does not
 * exist yet  a race that is rare in a test and routine under load, because the broker hop is
 * faster than the object store write.
 *
 * <p>Nothing here deletes on failure. If the publish is rejected the object is left behind, and the
 * retention sweep reclaims it; deleting eagerly would delete the object out from under a publish
 * that turned out to be ambiguous rather than rejected.

두 번째가 messaging-core-api의 3상태와 직접 연결된다 — REJECTEDAMBIGUOUS를 구분할 수 없는 시점에 삭제하면 모호한 발행의 payload를 지운다.

오프로드된 메시지는 payload를 아예 갖지 않는다.

// The published message carries no payload bytes at all, only the reference. Carrying both
// would double the transfer for no benefit and let the two disagree.
return new Offloaded(new byte[0], Optional.of(reference));

Offloaded record가 양방향 방어 복사를 한다(생성자 payload.clone(), 접근자 payload.clone()) — EncodedMessage(schema-api)·OutboxRecord(reliability-api)와 같은 패턴이다.

ClaimCheckStore.delete가 이 leaf에서 호출되지 않는다. 인터페이스에 선언돼 있고 publisher가 의도적으로 안 부른다("Nothing here deletes on failure"). 보존 sweep이 부를 것을 전제하는데 그 sweep이 이 leaf에 없다.

4.3 ClaimCheckIntegrityGuard — 세 검사, 전부 fail-closed
순서 검사 코드
1 reference.isExpired(now) CLAIM_CHECK_EXPIRED
2 payload.length != reference.sizeBytes() CLAIM_CHECK_SIZE_MISMATCH
3 sha256(payload) != reference.sha256() CLAIM_CHECK_DIGEST_MISMATCH
// :19-22
 * <p>Both checks fail closed. An expired reference is reported before the fetch, because a
 * not-found from the store is ambiguous between "reaped" and "never written". A digest mismatch is
 * reported as validation rather than deserialization, because the bytes are not corrupt JSON  they
 * are the wrong bytes.

크기 검사가 digest보다 먼저인 것이 합리적이다 — 크기 불일치는 SHA-256 계산 없이 즉시 판정된다.

sha256(byte[])HexFormat.of().formatHex(...)소문자 hex를 만든다. ClaimCheckReference의 정규식이 [a-f0-9]{64}이므로 두 쪽이 맞는다.

verify가 검증된 payload의 복사본을 반환한다.

4.4 ClaimCheckResolver — 만료를 fetch 전에 본다
if (claimCheck.isExpired(now)) {
  // Checked before fetching. A store that still returns the object past its retention would
  // otherwise hide a misconfiguration until the day the sweep caught up.
  throw new MessageValidationException("CLAIM_CHECK_EXPIRED", ...);
}

저장소가 아직 반환하더라도 거절한다. 보존 sweep이 늦게 도는 저장소에서 잘못된 설정이 숨는 것을 막는다.

fetchnullCLAIM_CHECK_NOT_FOUND로 번역하고 메시지가 두 원인을 나열한다 — "it was either reaped early or never written".

예외 승격이 코드 접미사로 판정된다.

} catch (MessageValidationException validation) {
  // A size or digest mismatch is a poison message, not a validation failure to be retried:
  // fetching the same key again returns the same wrong bytes.
  if (validation.failure().code().endsWith("_MISMATCH")) {
    throw new ClaimCheckIntegrityException(
        validation.failure().code(), validation.failure().sanitizedMessage());
  }
  throw validation;
}

endsWith("_MISMATCH")문자열 접미사로 분기한다. guard가 코드 이름을 바꾸거나 _MISMATCH로 끝나는 다른 코드를 추가하면 분류가 조용히 달라진다. §17.

4.5 ClaimCheckIntegrityException — 카테고리가 POISON_MESSAGE
// :12-18
 * <p>Not retryable. A digest mismatch means the object at that key is not the object the producer
 * wrote  the key was reused, the object was overwritten, or something truncated it  and fetching
 * it again returns the same wrong bytes. Retrying would only delay the dead-letter.
 *
 * <p>Deliberately distinct from "the object is gone". An expired claim check is an operational
 * problem with a known cause and a known fix; a digest mismatch means something wrote data nobody
 * expected, and the two must not be diagnosed as one.

FailureCategory.POISON_MESSAGE, retryable = false. messaging-core-apiFailureDescriptor.defaultRetryablePOISON_MESSAGE를 false로 두는 것과 일치한다.

이 예외가 MessagingException을 확장하는 저장소 내 두 곳 중 하나다(다른 하나는 core-api 자신의 23개). §A19-MESSAGING-CORE-API §12.1(b)가 그 사실을 관측했다.


5. 주요 실행 경로

발행: publisher.offload(encodedPayload) → 문턱 이하면 인라인 → 초과면 store.putOffloaded(빈 바이트, reference)

소비: resolver.resolve(inline, reference, now) → reference 없으면 인라인 → 만료 확인 → store.get → null이면 NOT_FOUND → guard.verify(만료·크기·digest) → _MISMATCHClaimCheckIntegrityException

두 경로 모두 production에서 호출되지 않는다(§12.1).


6. 실패 경로와 복구/번역

코드 예외 카테고리 retryable 조건
CLAIM_CHECK_RETENTION_TOO_SHORT MessagingConfigurationException CONFIGURATION false 정책 생성 시
CLAIM_CHECK_EXPIRED MessageValidationException PERMANENT_BUSINESS false 만료
CLAIM_CHECK_NOT_FOUND MessageValidationException PERMANENT_BUSINESS false 객체 없음
CLAIM_CHECK_SIZE_MISMATCH ClaimCheckIntegrityException POISON_MESSAGE false 크기 불일치
CLAIM_CHECK_DIGEST_MISMATCH ClaimCheckIntegrityException POISON_MESSAGE false digest 불일치

분류가 두 단계로 정확하다. 만료·부재는 운영 문제(PERMANENT_BUSINESS), 크기·digest 불일치는 오염(POISON_MESSAGE). 두 예외 클래스와 두 카테고리가 그 구분을 담는다.

ClaimCheckIntegrityGuard.sha256NoSuchAlgorithmExceptionIllegalStateException("Java runtime does not provide SHA-256")으로 감싼다 — 복구 불가능한 환경 문제이므로 메시지 실패가 아니다.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

ClaimCheckPublisher·ClaimCheckResolver는 final 필드만 갖는 불변 객체다. ClaimCheckIntegrityGuard는 상태가 없고 ClaimCheckResolver가 인스턴스를 필드로 하나 만든다.

MessageDigest.getInstance("SHA-256")호출마다 새 인스턴스를 만든다 — MessageDigest는 스레드 안전하지 않으므로 이것이 옳다. 재사용했다면 동시 호출이 서로의 상태를 오염시킨다.

ClaimCheckStore 구현의 스레드 안전성 요구는 인터페이스 javadoc에 없다.

수명주기 참여 없음.


8. 설정·기능 플래그·환경 차이

상수/기본값
ClaimCheckPolicy.DEFAULT_THRESHOLD_BYTES 262,144 (1 MiB의 1/4)
ClaimCheckPolicy.defaults() 문턱 256 KiB, 보존 3일, 브로커 보존 1일, 재전달 창 1일

설정 파일 없음. 모든 값이 생성자 인자다.


9. 퍼시스턴스/외부 시스템 세부

ClaimCheckStore가 객체 저장소를 가리키는 port다. 구현이 없다 — production에도, 다른 messaging leaf에도.

저장소의 adapter/outbound/objectstorage leaf가 후보 구현처이지만 두 leaf가 연결되지 않는다(messaging-claim-checkallowed_dependencies에 없고, 반대 방향도 없다).


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-claim-check:test. BUILD SUCCESSFUL, 22 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
ClaimCheckIntegrityGuardTest 6 만료·크기·digest 세 검사 실제 저장소
ClaimCheckResolverTest 8 인라인 통과, 만료 사전 거절, NOT_FOUND, _MISMATCH 승격 production 호출 여부
ClaimCheckRetentionValidatorTest 8 보존 불변식과 문턱 판정

ClaimCheckStore의 유일한 구현이 ClaimCheckResolverTest:22FakeStore다. 즉 이 leaf의 테스트가 자기 port의 유일한 구현을 제공한다.

ClaimCheckPublisher를 겨냥한 테스트 클래스가 없다. 오프로드 결정·객체 선기록 순서·Offloaded의 방어 복사가 이 레인에서 검증되지 않는다. 세 테스트 클래스 이름에 publisher가 없다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api","messaging-reliability-api"]
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 벤더 의존성 0
SecretLeakStaticScanTest(observability leaf) 이 leaf 소스도 스캔 대상
ArchUnit 전용 규칙 없음

12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt.

12.1 Public surface reachability

여섯 타입 전부 leaf 밖 참조 0이다.

타입 leaf 밖
ClaimCheckStore 0
ClaimCheckPolicy 0
ClaimCheckPublisher 0
ClaimCheckResolver 0
ClaimCheckIntegrityGuard 0
ClaimCheckIntegrityException 0

ClaimCheckStore 구현은 테스트 fake 하나뿐이고, 세 클래스의 생성이 leaf 밖에서 0건이다.

그런데 이 leaf는 배포 아티팩트에 실린다.

messaging-claim-check          runtime_memberships=['app-bootstrap']
messaging-spring-boot-starter  runtime_memberships=['app-bootstrap']
   starter deps include claim-check: True

messaging-cloudevents와 같은 조합이다. 형제 비교:

leaf 소비자 membership 정합
messaging-schema-avro 0 [] o
messaging-schema-protobuf 0 [] o
messaging-kafka-share-experimental 0 [] o
messaging-cloudevents 0 ["app-bootstrap"] x
messaging-claim-check 0 ["app-bootstrap"] x

"싣고 쓰지 않는" leaf가 둘이다. 오늘 실행되는 코드가 없으므로 사고는 아니다.

한 가지 정황이 이 leaf를 다르게 만든다. messaging-policyPayloadPolicyclaimCheckThresholdBytes 필드를 갖고, DestinationProfileValidator가 그 값을 검사한다(:49). 즉 목적지 프로파일은 claim check를 상정하고 있는데 그 상정을 실현하는 코드가 배선되지 않았다. payload가 문턱을 넘어도 오프로드되지 않고, PayloadLimitGuard가 상한 초과로 거절한다 — MessageTooLargeException("PAYLOAD_LIMIT_EXCEEDED", "... use claim check"). 에러 메시지가 존재하지 않는 경로를 권한다.

12.2 Conditional sibling comparison

Spring 주석 0개, bean 없음. starter가 이 leaf의 타입으로 만드는 bean도 없다.

messaging-reliability-api의 세 port 중 둘(OutboxRepository, InboxRepository)은 구현 leaf와 starter bean을 갖고 ClaimCheckStore는 둘 다 없다 — 같은 계열의 port 셋 중 하나만 미완이다.

12.3 Duplicate mechanism sweep

(a) claim check 문턱이 두 곳에 있고 서로를 모른다

위치 필드 검사
messaging-policy PayloadPolicy claimCheckThresholdBytes DestinationProfileValidator:49<= maxBytes 확인
이 leaf ClaimCheckPolicy thresholdBytes 생성자가 >= 1 확인

두 값을 대조하는 코드가 없다. 목적지 프로파일이 문턱 512 KiB를 선언하고 ClaimCheckPolicy가 256 KiB를 쓰면 둘 다 유효한 구성이고 실제 동작은 후자를 따른다. 오늘은 후자가 배선되지 않아 전자만 존재하므로 충돌하지 않는다.

(b) 보존/시간 관계 규칙이 두 곳에 있고 강제 강도가 다르다

규칙 위치 강제
claim check 보존 ≥ 브로커 보존 + 재전달 창 ClaimCheckPolicy 생성자 강제됨
inbox 보존 > 브로커 최대 재전달 창 InboxRepository javadoc 문서만

같은 종류의 규칙(“보존이 재전달 창보다 길어야 한다”)을 한 leaf는 생성자로 막고 다른 leaf는 문서로만 둔다. §A19-MESSAGING-RELIABILITY-API §17이 후자를 소유한다.

(c) digest 계산이 저장소에 여럿 있는가

MessageDigest.getInstance("SHA-256")을 쓰는 곳이 저장소에 여럿 있다(objectstorage, fileserver 등). 그러나 책임이 다르고(무결성 검증 vs 콘텐츠 주소화) runtime eligibility가 겹치지 않는다. 중복 경쟁 아님.

(d) _MISMATCH 접미사 분기

ClaimCheckResolver.verifyvalidation.failure().code().endsWith("_MISMATCH")로 예외를 승격한다. ClaimCheckIntegrityGuard의 코드 셋 중 둘이 그 접미사를 갖고 하나(CLAIM_CHECK_EXPIRED)가 갖지 않는다. 문자열 규약이 두 클래스 사이의 계약이 되어 있고 그것이 어디에도 선언되지 않았다. §17.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
ClaimCheckPolicy javadoc: 문턱이 "a quarter of the portable payload limit" 262,144 = 1,048,576 / 4 일치
ClaimCheckPublisher javadoc: 실패 시 삭제하지 않고 보존 sweep이 회수 이 leaf에 sweep 없음 미실현
ClaimCheckIntegrityGuard javadoc: 두 검사가 fail closed 세 검사 전부 예외 일치(검사가 셋인데 javadoc은 "Both")
PayloadLimitGuard 에러 메시지: "use claim check" claim check 경로 미배선 불일치
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift)

세 번째가 작은 표현 drift다 — javadoc이 "Both checks fail closed"라고 하는데 verify는 만료·크기·digest 셋을 검사한다. 크기 검사가 나중에 추가된 것으로 보인다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

이 leaf의 javadoc은 이전 결함을 서술하지 않는다 — 대신 막으려는 사고를 서술한다.

위치 막으려는 것
ClaimCheckPublisher 발행 후 저장 순서 → 존재하지 않는 객체의 참조를 소비자가 받음. "rare in a test and routine under load"
ClaimCheckPublisher 실패 시 즉시 삭제 → 모호한 발행의 payload를 지움
ClaimCheckResolver 검증 없는 fetch → 잘못된 키가 완벽히 디코딩되는 다른 객체를 반환
ClaimCheckResolver fetch 후 만료 확인 → sweep이 늦은 저장소에서 오설정이 숨음
ClaimCheckPolicy 짧은 보존 → 메시지와 무관한 이유로 dead-letter
ClaimCheckIntegrityException 만료와 불일치를 한 진단으로 합침

**"rare in a test and routine under load"**가 이 저장소 전반의 주제다 — messaging-observability의 카디널리티, messaging-security의 회전 경합, messaging-transport-spi의 자원 누수가 같은 형태다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-290 command evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt §A·§B 여섯 타입 참조 0, ClaimCheckStore 구현이 테스트 fake뿐, membership과 starter 의존, 두 문턱과 검사 위치 정적 검색
EVD-291 command ./gradlew :messaging:messaging-claim-check:test --rerun-tasks BUILD SUCCESSFUL, 22 / 0 / 0 저장소가 fake. publisher 미검증

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 두 시스템이 drift한다는 위협 모델 — ClaimCheckIntegrityGuard javadoc
  • 검증이 선택 불가인 이유 — ClaimCheckResolver javadoc
  • 저장이 발행보다 먼저인 이유 — ClaimCheckPublisher javadoc
  • 실패 시 삭제하지 않는 이유 — 같은 javadoc
  • payload와 참조를 함께 나르지 않는 이유 — 인라인 주석
  • 만료를 fetch 전에 보는 이유 — resolve 인라인 주석
  • 보존 규칙과 그것을 생성자가 강제하는 이유 — ClaimCheckPolicy javadoc
  • 문턱과 목적지 상한이 다른 이유 — 같은 javadoc
  • digest 불일치가 재시도 불가인 이유, 만료와 구분하는 이유 — ClaimCheckIntegrityException javadoc
  • _MISMATCH 승격이 poison message인 이유 — verify 인라인 주석

추론

  • 배선되지 않은 것이 미완인지 확장점인지 → 미상. ClaimCheckStore 구현이 없다는 관측만 있다.
  • _MISMATCH 접미사 규약이 의도인지 → 미상. 선언된 곳이 없다.
  • javadoc의 "Both checks"가 세 검사가 되기 전 표현인지 → 추론.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 6개 타입 418줄 전문의 계약
  • 22개 테스트가 통과하고 무엇을 단언하는지, 그리고 ClaimCheckPublisher가 미검증이라는 것
  • 여섯 타입 전부 leaf 밖 참조 0이고 ClaimCheckStore 구현이 테스트 fake뿐이라는 것
  • runtime_memberships["app-bootstrap"]이라 배포 아티팩트에 실린다는 것
  • PayloadLimitGuard의 에러 메시지가 배선되지 않은 경로를 권한다는 것
  • 문턱이 두 곳에 있고 대조되지 않는다는 것

확인하지 못한 것

  • ClaimCheckStore를 구현할 계획이 있는지. adapter/outbound/objectstorage가 후보이지만 두 leaf가 registry에서 연결되지 않는다.
  • 보존 sweep을 누가 도는지 — ClaimCheckStore.delete의 호출자가 없다.
  • 실제 객체 저장소에서 store.get이 만료 후에도 반환하는지 — resolve의 사전 만료 검사가 그 경우를 상정한다.
  • 두 문턱이 실제 배포에서 어긋나는지 — 한쪽이 배선되지 않아 관측 불가.

17. 손볼 것

P2 — 배포 아티팩트가 싣지만 아무도 부르지 않고, 다른 곳의 에러 메시지가 이 경로를 권한다
  • 사실. 여섯 타입 전부 leaf 밖 참조 0, ClaimCheckStore 구현이 테스트 fake뿐, 조립 0건. 그런데 runtime_memberships["app-bootstrap"]이고 starter의 allowed_dependencies에 포함된다. 그리고 messaging-policyPayloadLimitGuard가 상한 초과 payload를 거절하며 "payload of %d bytes exceeds the %d byte limit for %s; use claim check"라고 안내한다.
  • 근거. evidence/raw/290 §A. PayloadLimitGuard.java:46-49.
  • 왜 문제인가. 운영자가 상한 초과 오류를 보고 안내대로 claim check를 켜려 해도 켤 것이 없다 — 저장소 구현도, bean도, 오프로드를 부르는 발행 경로도 없다. 그리고 DestinationProfileclaimCheckThresholdBytes를 선언하고 검증까지 하므로 설정 표면은 존재한다. 설정할 수 있고 아무 효과가 없는 값이다.
  • 확인 방법. evidence/raw/290 §A 재실행. git grep -n 'use claim check' -- src.
  • 후보. (a) ClaimCheckStore 구현(objectstorage 어댑터 경유)과 발행 경로 배선. (b) 배선 전까지 membership을 []로 되돌리고 PayloadLimitGuard 메시지에서 안내를 뺀다. (c) 미완임을 support-matrix.md에 표시한다.
  • 다음 단계. CASE 후보. messaging-cloudevents §17의 "싣고 쓰지 않는다"와 같은 계열이지만, 여기서는 다른 컴포넌트가 이 경로를 권한다는 점이 추가된다.
P3 — claim check 문턱이 두 곳에서 독립적으로 정해진다
  • 사실. messaging-policyPayloadPolicy.claimCheckThresholdBytes(목적지별, DestinationProfileValidator:49가 검사)와 이 leaf의 ClaimCheckPolicy.thresholdBytes(전역). 두 값을 대조하는 코드가 없다.
  • 근거. evidence/raw/290 §B.
  • 왜 문제인가. 배선되면 실제 동작은 후자를 따르고 전자는 선언만 남는다. 목적지별로 다른 문턱을 두려던 설계가 전역 정책 하나에 덮인다.
  • 확인 방법. 두 필드와 검증기 확인.
  • 후보. ClaimCheckPublisher가 목적지 프로파일의 값을 읽거나, PayloadPolicy에서 그 필드를 제거한다.
  • 다음 단계. REFERENCE 후보(같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다).
P3 — 예외 승격이 에러 코드 문자열 접미사에 의존한다
  • 사실. ClaimCheckResolver.verifyvalidation.failure().code().endsWith("_MISMATCH")ClaimCheckIntegrityException 승격을 결정한다. ClaimCheckIntegrityGuard의 세 코드 중 둘이 그 접미사를 갖는다.
  • 근거. ClaimCheckResolver.java:84.
  • 왜 문제인가. 두 클래스 사이의 계약이 문자열 명명 규약이고 어디에도 선언되지 않았다. guard가 코드를 바꾸면(예: CLAIM_CHECK_DIGEST_INVALID) 승격이 조용히 멈추고 poison message가 PERMANENT_BUSINESS로 분류된다 — 재시도 정책이 달라진다.
  • 확인 방법. git grep -n '_MISMATCH' -- src/messaging/messaging-claim-check
  • 후보. guard가 두 종류의 예외를 직접 던지거나, 코드 집합을 상수로 선언하고 그것과 비교한다.
  • 다음 단계. CASE 후보 + REFERENCE 후보(타입 사이의 계약을 문자열 명명 규약으로 표현하지 않는다).
P3 — ClaimCheckPublisher가 이 leaf의 테스트에 등장하지 않는다
  • 사실. 세 테스트 클래스가 guard·resolver·policy를 겨냥한다. publisher 전용 테스트가 없다.
  • 근거. find src/test -name '*Test.java' → 셋.
  • 왜 문제인가. publisher가 소유한 결정 셋이 미검증이다 — 오프로드 판정(shouldOffload), 오프로드 시 payload를 비우는 것, Offloaded의 양방향 방어 복사. 특히 "저장이 발행보다 먼저"라는 순서는 publisher의 계약인데 그것을 확인하는 테스트가 없다.
  • 확인 방법. 세 테스트 클래스 이름 확인.
  • 후보. ClaimCheckPublisherTest를 추가한다.
  • 다음 단계. REFERENCE 후보(leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다).
P3 — 보존 sweep이 없다
  • 사실. ClaimCheckStore.delete가 선언돼 있고 이 leaf에서 호출되지 않는다. ClaimCheckPublisher javadoc이 "the retention sweep reclaims it"이라고 그 존재를 전제한다.
  • 근거. git grep -n 'delete(' -- src/messaging/messaging-claim-check → 인터페이스 선언만.
  • 왜 문제인가. 실패한 발행이 남긴 객체를 회수할 주체가 없다. 저장소 자체의 lifecycle 정책(예: S3 object expiration)이 대신할 수 있으나 ClaimCheckPolicy.retention이 그것과 연결되지 않는다.
  • 확인 방법. delete 호출자 검색.
  • 후보. sweep 작업을 만들거나, 저장소 lifecycle에 위임함을 javadoc에 명시한다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 ClaimCheckStore 구현 계획에 걸린다.
확인된 설계(문제 아님)
  • 보존 규칙(보존 ≥ 브로커 보존 + 재전달 창)을 생성자가 강제하는 것
  • 문턱과 목적지 상한을 분리하고 그 이유를 적은 것
  • 객체를 발행보다 먼저 저장하는 순서
  • 실패 시 삭제하지 않아 모호한 발행의 payload를 지키는 것
  • 오프로드 시 payload를 아예 비워 둘이 어긋날 여지를 없앤 것
  • 만료를 fetch 전에 확인해 저장소의 늦은 sweep이 오설정을 숨기지 않게 하는 것
  • 크기 검사를 digest보다 먼저 두는 것
  • 만료·부재와 크기·digest 불일치를 다른 카테고리로 분류하는 것
  • MessageDigest를 호출마다 새로 만드는 것

Source anchors

id kind path revision what it proves limitations
MCC-001 registry src/config/architecture/modules.json 21234e38 deps 2개, memberships ["app-bootstrap"] 선언
MCC-002 build messaging-claim-check/build.gradle same 벤더 의존성 0
MCC-003 code .../claimcheck/ClaimCheckPolicy.java same §4.1 보존 불변식과 문턱
MCC-004 code .../claimcheck/ClaimCheckPublisher.java same §4.2 순서·미삭제·빈 payload 전용 테스트 없음
MCC-005 code .../claimcheck/ClaimCheckIntegrityGuard.java same §4.3 세 검사
MCC-006 code .../claimcheck/ClaimCheckResolver.java same §4.4 사전 만료 확인, 접미사 승격 접미사 의존(§17)
MCC-007 code .../claimcheck/{ClaimCheckStore,ClaimCheckIntegrityException}.java same port 계약, POISON_MESSAGE 분류 구현 없음
MCC-008 test 3 클래스 / 22 테스트 same §10 표 fake 저장소. publisher 미검증
MCC-009 cross-leaf code messaging-policy/.../PayloadLimitGuard.java:46-49 same "use claim check" 안내 해당 leaf SSOT가 소유
MCC-010 cross-leaf code messaging-policy/.../PayloadPolicy.java:14, DestinationProfileValidator.java:49 same 두 번째 문턱과 그 검증 해당 leaf SSOT가 소유
EVD-290 command evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt same §12.1·§12.3 정적 검색
EVD-291 command ./gradlew :messaging:messaging-claim-check:test --rerun-tasks same 22 / 0 / 0

A19-MESSAGING-CLOUDEVENTS. messaging-cloudevents

분석 중에는 messaging/MESSAGING-CLOUDEVENTS.md 파일이었다. 594줄.

messaging-cloudevents 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-cloudevents SSOT owner: messaging-cloudevents integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-cloudevents
  • canonical state analysisFile: §A19-MESSAGING-CLOUDEVENTS
  • source path: src/messaging/messaging-cloudevents
  • registry allowed_dependencies: ["messaging-core-api", "messaging-schema-api"]
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 3
production LOC 228
패키지 1 (dev.caskeleton.messaging.cloudevents)
test 파일 1
test 메서드(실행 확인) 7
외부 의존성 2 (cloudevents-api:4.0.1 api, cloudevents-core:4.0.1 implementation)

세 타입: CloudEventMapper(인터페이스), DefaultCloudEventMapper(구현), CloudEventExtensions(확장 속성 이름 4개).

Coverage ledger
scope/file group count disposition reason
.../cloudevents/CloudEventMapper.java 1 FULL_READ 33줄 전문
.../cloudevents/DefaultCloudEventMapper.java 1 FULL_READ 171줄 전문
.../cloudevents/CloudEventExtensions.java 1 FULL_READ 24줄 전문
src/test/java/** 1 FULL_READ 162줄 전문
build.gradle 1 FULL_READ 주석 포함 17줄
gradle.lockfile 1 FULL_READ cloudevents 좌표 2건 확인
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

플랫폼 봉투와 CloudEvents 1.0.2 사이의 양방향 매퍼.

적용 범위를 인터페이스 javadoc이 한정한다.

// CloudEventMapper.java:11-13
 * <p>Offered for domain and integration events only. Commands and work items are not forced through
 * CloudEvents: they are internal contracts where the interoperability the specification buys does
 * not pay for the attributes it requires.

의존성 선언에 이 저장소에서 가장 자세한 근거 주석이 붙어 있다.

// api, because CloudEventMapper's public signatures return io.cloudevents.CloudEvent.
//
// Declared `implementation`, the type appeared in this module's public API while the
// dependency was hidden from consumers: an adopter calling the documented method could not
// name its return type without adding CloudEvents to their own build, and Gradle gave them no
// hint why. A type in a public signature is part of the artifact's contract.
api 'io.cloudevents:cloudevents-api:4.0.1'
implementation 'io.cloudevents:cloudevents-core:4.0.1'

둘의 scope가 다른 것이 정확하다. cloudevents-api(CloudEvent, CloudEventData)는 public 시그니처에 나오므로 api, cloudevents-core(CloudEventBuilder, BytesCloudEventData)는 구현 안에서만 쓰이므로 implementation이다. src/messaging/CLAUDE.md:40-43의 게이트가 잡는 구분을 두 좌표로 나눠 지켰다.

이 leaf의 위치가 형제들과 다르다. runtime_memberships["app-bootstrap"]이다 — 배포 아티팩트가 싣는다. 그런데 소비자가 하나도 없다(§12.1). Avro·Protobuf는 "싣지도 않고 쓰지도 않는다"로 정합하지만, 이 leaf는 싣고 쓰지 않는다.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), messaging-schema-api(api), cloudevents-api:4.0.1(api), cloudevents-core:4.0.1(implementation).

나가는 것: messaging-spring-boot-starterallowed_dependencies에 포함된다. 그래서 app-bootstrap → starter → 이 leaf 경로로 런타임 classpath에 오른다.

그러나 어떤 코드도 이 leaf의 타입을 부르지 않는다. starter의 어느 @BeanCloudEventMapper를 만들지 않고, 어느 클래스도 import하지 않는다(§12.1).

bean 없음(Spring 주석 0개).


3. 패키지/컴포넌트 지도

CloudEventMapper  (interface)
├── toCloudEvent(MessageEnvelope<?>, URI) → CloudEvent
└── fromCloudEvent(CloudEvent)            → MessageEnvelope<EncodedMessage>

DefaultCloudEventMapper  (구현)
├── toCloudEvent    : occurredAt 필수, payload는 이미 인코딩된 것만
├── fromCloudEvent  : time 필수, schemaversion 확장 필수
├── stringExtension / intExtension
└── producerFrom(URI)  : 마지막 세그먼트를 producer id로

CloudEventExtensions  (상수 4개)
correlationid · causationid · schemaversion · tenantcontext

CloudEventExtensions의 javadoc이 이름이 봉투 필드명과 다른 이유를 적는다 — "CloudEvents requires extension names to be lowercase alphanumeric".


4. 계약·불변식·상태 모델

4.1 매핑 표

봉투 → CloudEvent

봉투 CloudEvent 비고
messageId.value() id (String) UUID 문자열
source 호출자가 인자로 준다
messageType.value() type
occurredAt time 필수 — 없으면 거절
contentType.value() datacontenttype
schemaVersion.value() 확장 schemaversion 문자열로
correlationId 확장 correlationid 있을 때만
causationId 확장 causationid 있을 때만
tenantContext.tenantId() 확장 tenantcontext 있을 때만
payloadschemaReference.schemaUri dataschema 있을 때만
payload data EncodedMessage 또는 byte[]

CloudEvent → 봉투

CloudEvent 봉투 비고
id messageId UUID.fromStringMessageId(UUIDv7 강제)
type messageType
time producedAt occurredAt 같은 값이 둘에 들어간다
source producer 마지막 세그먼트만
확장 schemaversion schemaVersion 필수
확장 correlationid correlationId
확장 causationid causationId UUID.fromStringMessageId
확장 tenantcontext tenantContext
datacontenttype (없으면 application/json) contentType
data (없으면 new byte[0]) EncodedMessage
partitionKey, orderingKey 항상 empty
traceContext 항상 TraceContext.none()
headers 항상 MessageHeaders.empty()
4.2 두 가지 명시적 매핑 결정
// DefaultCloudEventMapper.java:31-35
 * <p>Two mapping decisions are deliberate. An event without {@code occurredAt} is rejected rather
 * than defaulted to the production instant, because {@code time} is read downstream as when the
 * fact happened, not when the platform got around to serialising it. And an event with no data maps
 * to an envelope with empty bytes, never to a Kafka null value: a tombstone deletes a key, and
 * inventing one from an absent CloudEvent payload would turn an empty notification into a deletion.

두 번째는 messaging-core-apiMessageEnvelope javadoc과 정확히 짝을 이룬다 — "A null Kafka value is a tombstone, which is a distinct broker-native operation with different retention semantics." 봉투가 payload를 non-null로 강제한 이유가 여기서 실제 매핑 규칙으로 나타난다.

4.3 producerFrom: 무한 URI를 유한 이름으로
// :158-163
 * <p>The last path or scheme-specific segment is used so that a long URI does not become an
 * unbounded producer name, which would leak straight into metric tags.
private static String producerFrom(URI source) {
  String text = source.toString();
  int separator = Math.max(text.lastIndexOf('/'), text.lastIndexOf(':'));
  String candidate =
      separator >= 0 && separator + 1 < text.length() ? text.substring(separator + 1) : text;
  return candidate.isBlank() ? "unknown" : candidate;
}

ProducerId가 "deployment-independent service name, not a host, pod, or connection identity, so that it stays a bounded value safe for metric tags"라고 선언한 것과 같은 관심사다.

다만 이 방어는 완전하지 않다. 마지막 세그먼트가 여전히 120 UTF-8 바이트를 넘거나 제어문자를 담을 수 있다. 그 경우 ProducerId 생성자가 IllegalArgumentException을 던진다 — §4.5.

urn:service:order-apiorder-api(테스트가 쓰는 형태). https://a.example/very/long/path/xx.

4.4 time이 두 필드로 복제된다
Instant occurredAt = time.toInstant();
return new MessageEnvelope<>(
    ..., occurredAt,            // producedAt
    Optional.of(occurredAt),    // occurredAt
    ...);

CloudEvents에는 time 하나뿐이므로 봉투의 두 시각(플랫폼이 봉투를 만든 때 / 사실이 일어난 때)을 구분할 수 없다. 같은 값을 넣는 것은 합리적 선택이지만 정보 손실이 기록되지 않았다 — 왕복 후 producedAt은 원래 값이 아니다. 테스트의 왕복 검증(roundTripsBackToAnEnvelopeWithoutInventingATombstone)이 messageId·messageType·schemaVersion·correlationId·tenantContext·payload만 비교하고 producedAt은 비교하지 않는다. fixture에서 producedAt09:15:01Z, occurredAt09:15:00Z일부러 다르게 설정돼 있으므로, 비교했다면 실패했을 것이다.

4.5 왕복에서 소실되는 것

fromCloudEvent가 항상 비우는 필드가 다섯이다.

필드 결과
partitionKey Optional.empty()
orderingKey Optional.empty()
traceContext TraceContext.none()
headers MessageHeaders.empty()
producedAt occurredAt으로 덮임

traceContext의 소실이 가장 무겁다. messaging-core-apiTraceContext javadoc이 그 필드를 봉투에 둔 이유를 적는다 — "Keeping them on the envelope rather than only in headers means a trace survives an Outbox round trip through the database, where broker headers do not exist yet." CloudEvents 왕복은 그 보존을 깨뜨린다. CloudEvents는 분산 추적 확장(traceparent를 distributed-tracing extension으로)을 정의하는데 이 매퍼는 그것을 읽지도 쓰지도 않는다.

toCloudEventtraceContext·headers·partitionKey·orderingKey를 쓰지 않는다. 즉 소실은 양방향이다.

4.6 id의 UUIDv7 강제 — 이 leaf에서 가장 중요한 계약
new MessageId(UUID.fromString(event.getId()))

CloudEvents 1.0.2는 id를 **"Type: String; Constraints: REQUIRED, MUST be a non-empty string"**으로 정의한다. UUID 형식 요구가 없다.

MessageId(messaging-core-api)는 UUID이면서 version 7 · variant 2를 요구한다.

두 계약이 만나는 지점의 실제 동작을 런타임 probe로 확인했다(evidence/raw/273-cloudevents-inbound-id-probe.txt).

--- spec-conformant opaque string id
    id = A234-1234-1234
    result = REJECTED
    thrown = java.lang.IllegalArgumentException
    message = Invalid UUID string: A234-1234-1234
    is a MessagingException (carries FailureDescriptor) = false

--- UUIDv4 id
    id = 9c1f1f2e-6a1a-4d3b-8f0e-2b0d5b2f6c11
    result = REJECTED
    thrown = java.lang.IllegalArgumentException
    message = a message identity is UUIDv7 (time-ordered); this is version 4
    is a MessagingException (carries FailureDescriptor) = false

--- UUIDv7 id (what this platform mints)
    result = ACCEPTED

A234-1234-1234는 CloudEvents 명세 자신의 예시가 쓰는 id다.

의도는 문서화돼 있다. 테스트에 주석이 있다.

// CloudEventMappingTest.java:90-91
// A v7 id: MessageId enforces the version it documents, so a v4 arriving from a foreign
// producer is refused here exactly as it would be on the wire.

즉 "외부 producer의 v4를 거절한다"는 것은 알고 내린 결정이다. 그러나 두 가지가 그 결정과 별개다.

  1. 비UUID id는 명세 위반이 아니다. v4 거절은 정책 선택이지만, A234-1234-1234 거절은 CloudEvents 상호운용성 자체를 포기하는 것이다. 그리고 그 경우는 어디에도 언급되지 않았다.
  2. 실패가 플랫폼 어휘 밖이다. 이 매퍼의 다른 모든 검증 실패는 MessageValidationException(→ FailureDescriptor, PERMANENT_BUSINESS, 안정 코드)이다. id 실패만 raw IllegalArgumentException이다. 분류도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드 집계가 이 실패를 못 본다.

§17에서 다룬다.

4.7 schemaversion 확장이 필수다
private static int intExtension(CloudEvent event, String name) {
  return stringExtension(event, name)
      .map(value -> { try { return Integer.valueOf(value); }
                      catch (NumberFormatException e) {
                        throw new MessageValidationException("CLOUDEVENT_SCHEMA_VERSION_INVALID", ...); } })
      .orElseThrow(() -> new MessageValidationException("CLOUDEVENT_SCHEMA_VERSION_REQUIRED",
          "schemaversion extension is required by this profile"));
}

에러 메시지가 "by this profile"이라고 적어 이것이 명세 요구가 아니라 이 프로파일의 요구임을 밝힌다. 좋은 표현이다 — id의 UUIDv7 요구에는 그런 표시가 없다.

이 확장을 쓰지 않는 외부 producer의 CloudEvent는 전부 거절된다. id와 합치면 이 매퍼가 받아들이는 CloudEvent는 사실상 이 플랫폼이 만든 것뿐이다.

4.8 toCloudEvent의 payload 계약
if (envelope.payload() instanceof EncodedMessage encoded) { ... }
else if (envelope.payload() instanceof byte[] bytes) { builder.withData(BytesCloudEventData.wrap(bytes.clone())); }
else { throw new MessageValidationException("CLOUDEVENT_PAYLOAD_NOT_ENCODED", ...); }

이미 인코딩된 것만 받는다 — 매퍼가 codec 역할을 하지 않는다. byte[] 분기에서 clone()하는 것도 EncodedMessage.bytes()가 이미 복사본을 주는 것과 대칭이다.


5. 주요 실행 경로

나가는 방향: occurredAt 확인(없으면 거절) → CloudEventBuilder.v1()에 id·source·type·time·datacontenttype·schemaversion → 선택 확장 셋 → payload 종류 판정 → dataschema(있을 때) → build()

들어오는 방향: time 확인(없으면 거절) → datacontenttype(기본 application/json) → data(없으면 빈 배열) → MessageId·MessageType·SchemaVersion·ProducerId·확장 셋 → MessageEnvelope 조립


6. 실패 경로와 복구/번역

코드 예외 방향 조건
CLOUDEVENT_TIME_REQUIRED MessageValidationException 양방향 occurredAt 없음 / time 없음
CLOUDEVENT_PAYLOAD_NOT_ENCODED MessageValidationException 나가는 payload가 EncodedMessagebyte[]도 아님
CLOUDEVENT_SCHEMA_VERSION_REQUIRED MessageValidationException 들어오는 확장 없음
CLOUDEVENT_SCHEMA_VERSION_INVALID MessageValidationException 들어오는 확장이 정수가 아님
(코드 없음) IllegalArgumentException 들어오는 id가 UUID가 아니거나 v7이 아님
(코드 없음) IllegalArgumentException 들어오는 causationid가 UUID가 아니거나 v7이 아님
(코드 없음) IllegalArgumentException 들어오는 typeMessageType 제약 위반(240바이트·제어문자)
(코드 없음) IllegalArgumentException 들어오는 correlationid가 160바이트 초과
(코드 없음) IllegalArgumentException 들어오는 tenantcontext가 슬러그 패턴 위반
(코드 없음) IllegalArgumentException 들어오는 유도된 producer 이름이 120바이트 초과 또는 제어문자
(코드 없음) IllegalArgumentException 들어오는 datacontenttype이 미디어 타입 문법 위반
(코드 없음) IllegalArgumentException 들어오는 schemaversion이 0 이하

분류된 실패 넷, 분류되지 않은 실패 여덟. 매퍼가 직접 던지는 것은 전부 MessageValidationException이지만, 값 객체 생성자에 위임한 검증은 전부 raw IllegalArgumentException이다. fromCloudEvent외부에서 온 데이터를 다루는 유일한 진입점인데, 그 진입점의 실패 대부분이 플랫폼 실패 어휘 밖이다.

messaging-core-apiFailureDescriptor 설계 전체가 "예외 클래스로 분기하지 말고 선언된 분류로 판단하라"였다. 이 경로는 그 분류를 만들지 않는다.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

DefaultCloudEventMapper상태가 없다 — 필드가 SPEC_CONTENT_TYPE_FALLBACK 상수 하나뿐이고 모든 메서드가 인자만 쓴다. 스레드 안전하다. 다만 그 사실이 javadoc에 적혀 있지 않다.

CloudEventExtensions는 상수 홀더이고 private 생성자를 갖는다.

CloudEventBuilder는 호출마다 새로 만들어진다.


8. 설정·기능 플래그·환경 차이

설정 없음.

상수 위치
SPEC_CONTENT_TYPE_FALLBACK "application/json" DefaultCloudEventMapper.java:39 (private)
CloudEventExtensions.CORRELATION_ID "correlationid" public
CloudEventExtensions.CAUSATION_ID "causationid" public
CloudEventExtensions.SCHEMA_VERSION "schemaversion" public
CloudEventExtensions.TENANT_CONTEXT "tenantcontext" public

CloudEvents 버전은 4.0.1로 고정(lockfile 확인). CloudEvents 명세 버전은 CloudEventBuilder.v1()이 고정한다 — javadoc은 1.0.2를 명시한다.


9. 퍼시스턴스/외부 시스템 세부

없다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-cloudevents:test. BUILD SUCCESSFUL, 7 tests, 0 skipped, 0 failures.

테스트 증명하는 것
mapsLogicalIdentityAndExtensions id·type·schemaversion·source·datacontenttype
mapsCorrelationAndTenantAsExtensions 두 확장
mapsOccurredAtToEventTime occurredAttime
rejectsAnEventEnvelopeWithoutOccurredAt 나가는 방향의 time 필수
roundTripsBackToAnEnvelopeWithoutInventingATombstone 왕복 시 6개 필드 보존
aCloudEventWithNoDataBecomesAnEmptyPayloadNotANullValue 빈 data → 빈 바이트(tombstone 아님)
rejectsAnUnencodedPayload 인코딩되지 않은 payload 거절

이 레인의 결정적 한계: 모든 입력이 이 플랫폼이 만든 것이다.

fromCloudEvent를 부르는 두 테스트 중 하나는 mapper.toCloudEvent(original, SOURCE)의 출력을 되돌리고, 다른 하나는 MessageId.newId()로 v7 id를 만들어 CloudEvent를 조립한다. 후자에는 주석이 붙어 있다 — "A v7 id: MessageId enforces the version it documents".

외부 producer가 만든 CloudEvent를 이 매퍼에 넣는 경로가 한 번도 테스트되지 않았다. 이 leaf의 존재 이유가 상호운용성인데, 상호운용 방향이 검증 공백이다. §4.6의 probe가 그 공백을 실제로 실행해 본 결과다.

왕복 검증의 선택적 비교. roundTripsBackToAnEnvelopeWithoutInventingATombstoneproducedAt·traceContext·headers·partitionKey·orderingKey를 비교하지 않는다. fixture는 producedAt(09:15:01Z)과 occurredAt(09:15:00Z)을 다르게 두었으므로, 비교했다면 실패했을 것이다. 테스트 이름이 "roundTrips"인데 실제로는 6개 필드의 부분 보존을 확인한다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api","messaging-schema-api"]
verifyRuntimeModuleMembership ["app-bootstrap"] — 편입이 강제됨
vendor api 규칙(src/messaging/CLAUDE.md:40-43) cloudevents-api는 public 시그니처에 등장 → api. cloudevents-core는 구현 전용 → implementation. 통과
ArchUnit 전용 규칙 없음

12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/272-schema-family-reachability.txt, evidence/raw/273-cloudevents-inbound-id-probe.txt.

12.1 Public surface reachability
타입 leaf 밖 참조 판정
CloudEventMapper 0 소비자 없음
DefaultCloudEventMapper 0 소비자 없음
CloudEventExtensions 0 소비자 없음

세 타입 모두 git grep exit 1.

형제와 다른 조합이다.

leaf 소비자 starter codec 등록 runtime_memberships 정합
messaging-schema-json 1 o ["app-bootstrap"] o
messaging-schema-avro 0 x [] o
messaging-schema-protobuf 0 x [] o
messaging-cloudevents 0 해당 없음 ["app-bootstrap"] x

Avro·Protobuf는 "싣지 않고 쓰지 않는다"로 정합한다. 이 leaf는 싣고 쓰지 않는다. messaging-spring-boot-starterallowed_dependencies에 들어 있어 배포 아티팩트가 cloudevents-apicloudevents-core 두 jar를 함께 싣는다.

지금 그것이 사고는 아니다 — 아무도 부르지 않으므로 코드가 실행되지 않는다. 비용은 아티팩트 크기와, "이 의존성이 왜 여기 있지?"를 나중에 조사할 사람의 시간이다.

12.2 Conditional sibling comparison

Spring 주석 0개, bean 없음.

조립 비대칭은 starter 쪽에서 관측된다. MessagingCoreAutoConfigurationJacksonMessageCodec으로 codec registry를 만드는 @Bean을 갖는데, CloudEventMapper를 만드는 @Bean은 없다. 두 leaf 모두 starter의 의존 목록에 있고 한쪽만 배선된다. 상세는 messaging-spring-boot-starter leaf SSOT가 소유한다.

12.3 Duplicate mechanism sweep

(a) 다른 CloudEvents 구현이 있는가 — 없다

git grep -l 'io.cloudevents' -- src가 이 leaf 밖에서 맞추는 것이 없다. 저장소에 CloudEvents를 다루는 코드는 이 세 파일뿐이다.

(b) 봉투 ↔ 외부 표현 매핑이 다른 곳에도 있는가 — 있다, 그러나 책임이 다르다

messaging-kafkaKafkaHeaderMapper/KafkaDeliveryMapper, messaging-rabbitRabbitDeliveryMapper가 봉투를 브로커 표현으로 옮긴다. 그러나 그들은 transport 매핑이고 이것은 interchange 포맷 매핑이다. runtime eligibility가 겹치지 않는다(브로커 매퍼는 항상 실행되고 이것은 명시 호출이 필요하다).

다만 겹치는 관심사가 하나 있다 — traceContext. 브로커 매퍼들은 traceparent/tracestate/baggage를 예약 헤더로 실어 나르고(ReservedHeaders가 세 이름을 갖는다), 이 매퍼는 그것을 버린다(§4.5). 같은 봉투 필드를 두 경로가 다르게 취급한다.

(c) UUID 파싱UUID.fromString을 통한 외부 문자열 → 식별자 변환이 이 leaf에서 두 곳(id, causationid)에 있고 둘 다 방어가 없다. 저장소의 다른 곳에서는 대체로 값 객체가 그 방어를 갖는다.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
build.gradle 주석: cloudevents-api가 public 시그니처에 등장 CloudEventMapper의 두 메서드가 CloudEvent를 반환/수취 일치
build.gradle 주석: cloudevents-core는 구현 전용 CloudEventBuilder·BytesCloudEventDataDefaultCloudEventMapper 안에서만 일치
클래스 javadoc: "CloudEvents 1.0.2 compatible profile" id 제약이 명세보다 엄격(§4.6). schemaversion 확장 필수 부분 불일치 — 아래 참조
CloudEventMapper javadoc: domain/integration event 전용 코드에 그 구분을 강제하는 것 없음 미강제 — 정책 진술이고 게이트가 없다
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치 — family drift의 사례(messaging-core-api §12.4)

"compatible profile"의 정확한 의미. 명세는 id를 임의의 비어 있지 않은 문자열로 정의하고, 이 프로파일은 UUIDv7만 받는다. 나가는 방향은 명세를 만족한다(UUID 문자열은 유효한 id다). 들어오는 방향은 명세 준수 이벤트의 부분집합만 받는다. javadoc의 "compatible"이 어느 방향을 말하는지 밝히지 않는다. schemaversion 에러 메시지는 "required by this profile"이라고 정확히 적는 반면 id 제약에는 그런 표시가 없다 — 같은 파일 안에서 표현의 정밀도가 다르다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

build.gradle 주석이 이전 결함 하나를 보존한다.

Declared implementation, the type appeared in this module's public API while the dependency was hidden from consumers: an adopter calling the documented method could not name its return type without adding CloudEvents to their own build, and Gradle gave them no hint why. A type in a public signature is part of the artifact's contract.

이것이 src/messaging/CLAUDE.md:40-43의 게이트를 만든 사례군에 속한다 — "source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 build.gradleapi로 선언했는지 대조". 이 leaf는 그 게이트를 두 좌표로 나눠 통과한 모범 사례다.

코드 주석이 남긴 두 매핑 결정(§4.2)도 실패 이력의 성격을 갖는다 — "defaulted to the production instant"와 "inventing a tombstone"은 하지 않기로 한 것들이다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-272 command evidence/raw/272-schema-family-reachability.txt §D, §E 세 타입의 소비자 0, membership ["app-bootstrap"] 정적 검색
EVD-273 runtime probe evidence/raw/273-cloudevents-inbound-id-probe.txt 명세 예시 id·UUIDv4·UUIDv7 세 경우의 실제 결과와 예외 타입, MessagingException 여부 저장소 소스를 수정하지 않은 별도 probe. 세 id 형태만 확인
EVD-278 command ./gradlew :messaging:messaging-cloudevents:test --rerun-tasks BUILD SUCCESSFUL, 7 / 0 / 0 외부 producer 입력 없음

EVD-273의 실행 방법: :messaging:messaging-cloudevents test runtimeClasspath에 대해 /tmp/CeProbe.java를 컴파일·실행. 저장소 파일은 읽기만 했다.


15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • domain/integration event 전용인 이유 — CloudEventMapper javadoc
  • occurredAt 없는 이벤트를 거절하는 이유 — DefaultCloudEventMapper javadoc
  • 빈 data를 tombstone으로 만들지 않는 이유 — 같은 javadoc
  • producer 이름을 마지막 세그먼트로 자르는 이유 — producerFrom javadoc
  • 확장 이름이 봉투 필드명과 다른 이유 — CloudEventExtensions javadoc
  • cloudevents-apiapi여야 하는 이유 — build.gradle 주석
  • v4 id를 거절하는 것이 의도라는 것 — 테스트 주석(CloudEventMappingTest.java:90-91)

추론

  • 비UUID id 거절이 의도인지 → 미상. 테스트 주석은 v4만 언급하고 비UUID는 언급하지 않는다. 두 경우는 다른 판단이다.
  • traceContext·headers를 버리는 것이 의도인지 → 미상. 어디에도 언급이 없다.
  • producedAtoccurredAt으로 덮는 것이 의도인지 → 추론. CloudEvents에 time이 하나뿐이라는 제약에서 나온 것으로 보이지만 주석이 없다.
  • membership이 있고 소비자가 없는 이유 → 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 세 타입 228줄 전문의 매핑 계약, 양방향 필드 대응표
  • 7개 테스트가 통과하고 무엇을 단언하는지, 그리고 무엇을 비교하지 않는지
  • 소비자 0인데 runtime_memberships["app-bootstrap"]이라는 비정합
  • 명세 예시 id와 UUIDv4가 분류되지 않은 IllegalArgumentException으로 거절된다는 것 — 런타임 probe로 실행 확인
  • 왕복에서 다섯 필드가 소실된다는 것
  • api/implementation 분리가 정확하다는 것

확인하지 못한 것

  • 실제 외부 CloudEvents producer(예: Knative, Azure Event Grid)의 id 형식 분포. 명세가 제약하지 않으므로 UUID가 아닐 가능성이 높지만 측정하지 않았다.
  • 이 leaf가 starter 의존 목록에 들어간 시점과 이유. 커밋이 4개뿐이고 전부 대량 커밋이다.
  • dataschema가 실제로 쓰이는지 — EncodedMessage.schemaReference().schemaUri()가 채워지는 경로가 이 저장소에 없다(세 codec 모두 SchemaReference.of(subject, version)로 URI 없이 만든다). 즉 dataschema는 현재 항상 비어 있다.
  • CloudEvents distributed-tracing extension을 쓸 계획이 있는지.

17. 손볼 것

P2 — 상호운용을 위한 매퍼가 명세 준수 이벤트를 분류되지 않은 예외로 거절한다
  • 사실. fromCloudEventnew MessageId(UUID.fromString(event.getId()))로 id를 파싱한다. CloudEvents 1.0.2는 id를 비어 있지 않은 문자열로만 제약한다. 런타임 probe 결과: 명세 예시 id A234-1234-1234java.lang.IllegalArgumentException: Invalid UUID string, UUIDv4 → java.lang.IllegalArgumentException: a message identity is UUIDv7. 둘 다 MessagingException이 아니다.
  • 근거. evidence/raw/273-cloudevents-inbound-id-probe.txt (실행 확인). DefaultCloudEventMapper.java:116.
  • 왜 문제인가. 두 층이다.
    • (1) 범위. v4 거절은 의도이고 테스트 주석이 그렇게 적는다. 그러나 비UUID 거절은 어디에도 언급되지 않았고 그것은 다른 판단이다 — v4 거절은 "우리 정책", 비UUID 거절은 "CloudEvents 상호운용 포기"다. 이 leaf의 존재 이유가 상호운용인데 명세 예시조차 받지 못한다.
    • (2) 실패 어휘. 같은 메서드의 다른 검증 실패 넷은 전부 MessageValidationException이고 안정 코드(CLOUDEVENT_TIME_REQUIRED 등)를 갖는다. id 실패만 raw IllegalArgumentException이라 FailureDescriptor가 없다 — 카테고리도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드가 이 실패를 분류하지 못한다. 같은 문제가 causationid·type·correlationid·tenantcontext·producer·datacontenttype·schemaversion 값 범위에도 있다(§6의 "코드 없음" 여덟 행).
  • 확인 방법. evidence/raw/273의 probe 재실행. 또는 MessageId 생성자와 UUID.fromString의 계약 대조.
  • 후보. (a) fromCloudEvent의 값 객체 생성을 전부 감싸 MessageValidationException으로 번역하고 각각 안정 코드를 준다. (b) 비UUID id에 대해 결정한다 — 거절하되 명시적으로 하거나, id를 그대로 보존하는 필드를 두거나, 결정론적 UUIDv5/v7으로 유도한다. (c) javadoc의 "compatible profile"이 나가는 방향만 뜻함을 밝힌다.
  • 다음 단계. CASE 후보. 재현이 실행 evidence로 확정됐고 결론이 leaf 경계 안에서 닫힌다. (b)의 선택은 별도 DECISION 후보이며 지금은 근거가 없으므로 NEEDS_DECISION이다.
P2 — 배포 아티팩트가 싣지만 아무도 부르지 않는다
  • 사실. 세 타입의 leaf 밖 참조가 0인데 runtime_memberships["app-bootstrap"]이다. messaging-spring-boot-starter의 의존 목록에 있어 cloudevents-api·cloudevents-core 두 jar가 런타임 classpath에 오른다. starter에 CloudEventMapper를 만드는 @Bean이 없다.
  • 근거. evidence/raw/272 §D·§E. MessagingCoreAutoConfiguration 전수(CloudEvent 참조 0).
  • 왜 문제인가. 형제 Avro·Protobuf는 소비자 0과 membership []이 일치하는 정합적 incubating 상태다. 이 leaf만 어긋난다. 오늘 실행되는 코드가 없으므로 사고는 아니지만, 아티팩트 크기와 "이 의존성이 왜 있지"의 조사 비용이 남는다. 그리고 support-matrix.md:23이 "모든 messaging leaf가 unwired"라고 적고 있어 문서에서도 이 사실을 알 수 없다.
  • 확인 방법. git grep -l -w CloudEventMapper -- src ':!src/messaging/messaging-cloudevents' → exit 1. registry의 membership 확인.
  • 후보. (a) starter에서 @ConditionalOnClass/@ConditionalOnProperty로 mapper bean을 배선한다. (b) starter 의존에서 빼고 membership을 []로 되돌려 Avro·Protobuf와 같은 상태로 만든다.
  • 다음 단계. CASE 후보. "장치는 있고 회로가 닫히지 않았다"의 변형 — 여기서는 회로가 닫히지 않았는데 부품은 배송됐다.
P3 — 왕복이 다섯 필드를 버리고, 테스트가 그 필드를 비교하지 않는다
  • 사실. fromCloudEventpartitionKey·orderingKey를 empty로, traceContextnone()으로, headersempty()로 두고, producedAtoccurredAt 값으로 덮는다. 왕복 테스트는 6개 필드만 비교하고 이 다섯은 비교하지 않는다. fixture의 producedAt(09:15:01Z)과 occurredAt(09:15:00Z)이 다르므로 비교했다면 실패했을 것이다.
  • 근거. DefaultCloudEventMapper.java:115-131, CloudEventMappingTest.java:72-84, 143-161.
  • 왜 문제인가. traceContext 소실이 가장 무겁다. messaging-core-apiTraceContext javadoc이 그 필드를 봉투에 둔 이유를 "a trace survives an Outbox round trip through the database, where broker headers do not exist yet"이라고 적는다. CloudEvents 왕복이 그 보존을 깨뜨리고, CloudEvents 자신이 정의하는 distributed-tracing extension을 쓰지 않는다. 그리고 테스트 이름이 roundTrips…인데 실제로는 부분 보존 확인이다.
  • 확인 방법. 왕복 테스트에 producedAt·traceContext 비교를 추가하면 실패한다.
  • 후보. (a) 소실 필드를 javadoc에 명시한다. (b) traceparent/tracestate/baggage를 CloudEvents distributed-tracing extension으로 왕복시킨다. (c) 테스트 이름을 실제 보장에 맞춘다.
  • 다음 단계. REFERENCE 후보(왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다).
P3 — dataschema가 채워질 경로가 없다
  • 사실. toCloudEventencoded.schemaReference().flatMap(SchemaReference::schemaUri).ifPresent(builder::withDataSchema)dataschema를 채운다. 그런데 세 codec(JSON·Avro·Protobuf) 모두 SchemaReference.of(subject, version)로 만들고, 그 factory는 schemaUriOptional.empty()로 둔다.
  • 근거. DefaultCloudEventMapper.java:81-86, SchemaReference.java:36-38, 세 codec의 encode.
  • 왜 문제인가. dataschema는 CloudEvents 소비자가 페이로드를 해석하는 데 쓰는 표준 속성이다. 항상 비어 있으므로 이 프로파일이 만드는 CloudEvent는 스키마 위치를 알리지 않는다. schemaversion 확장이 그 자리를 대신하지만 그것은 비표준 확장이다.
  • 확인 방법. git grep -n 'new SchemaReference(' -- 'src/messaging/**/*.java' — 3인자 생성자를 부르는 production 코드가 있는지 확인.
  • 후보. schema registry URI를 갖는 배포에서 SchemaReference의 3인자 생성자를 쓰게 하거나, dataschema 분기가 현재 도달 불가임을 주석으로 남긴다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 "이 저장소가 외부 schema registry를 쓸 것인가"에 걸리고, 그 질문은 messaging-schema-apiSchemaRegistry port가 구현 0인 것과 같은 뿌리다.
P3 — CloudEventMapper javadoc의 범위 제한이 강제되지 않는다
  • 사실. "Offered for domain and integration events only. Commands and work items are not forced through CloudEvents." 코드에 DestinationKind를 보는 분기가 없다.
  • 근거. CloudEventMapper.java:11-13, DefaultCloudEventMapper 전문.
  • 왜 문제인가. 소비자가 0이므로 지금은 무해하다. 배선되면 ASYNC_COMMAND·WORK_QUEUE 봉투도 이 매퍼를 통과한다.
  • 확인 방법. git grep -n 'DestinationKind' -- 'src/messaging/messaging-cloudevents/**' → 매치 없음.
  • 후보. 진술을 유지하되 "호출자 책임"임을 명시하거나, toCloudEventDestinationKind를 받아 검사한다.
  • 다음 단계. REFERENCE 후보(문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다).
확인된 설계(문제 아님)
  • occurredAt 없는 이벤트를 production 시각으로 기본값 처리하지 않고 거절하는 것
  • 빈 data를 tombstone(Kafka null value)으로 만들지 않는 것 — MessageEnvelope의 non-null payload 계약과 정확히 짝을 이룸
  • producer 이름을 마지막 세그먼트로 잘라 메트릭 카디널리티를 막는 것
  • cloudevents-apiapi로, cloudevents-coreimplementation으로 나눈 것과 그 근거 주석
  • schemaversion 에러 메시지가 "by this profile"이라고 밝히는 것
  • byte[] payload를 clone()해서 넘기는 것
  • 매퍼가 상태를 갖지 않는 것

Source anchors

id kind path revision what it proves limitations
MCE-001 registry src/config/architecture/modules.json 21234e38 deps, runtime_memberships: ["app-bootstrap"] 선언
MCE-002 build messaging-cloudevents/build.gradle same api/implementation 분리와 그 근거
MCE-003 build messaging-cloudevents/gradle.lockfile:33-34 same cloudevents 4.0.1 두 좌표
MCE-004 code .../cloudevents/CloudEventMapper.java 전문 same 계약과 적용 범위 진술 범위 미강제(§17)
MCE-005 code .../cloudevents/DefaultCloudEventMapper.java 전문 same §4 전체 매핑표와 두 명시적 결정
MCE-006 code .../cloudevents/CloudEventExtensions.java same 확장 이름 4개와 명명 이유
MCE-007 test CloudEventMappingTest (7) same §10 표 외부 producer 입력 없음. 왕복이 5개 필드 미비교
MCE-008 cross-leaf code messaging-core-api/.../MessageId.java:20-32 same UUIDv7 강제의 출처 해당 leaf SSOT가 소유
MCE-009 cross-leaf code messaging-core-api/.../TraceContext.java:11-13 same 봉투가 trace를 갖는 이유(§17 왕복 소실) 해당 leaf SSOT가 소유
MCE-010 cross-leaf code messaging-schema-api/.../SchemaReference.java:36-38 same of가 URI를 비움 → dataschema 도달 불가 해당 leaf SSOT가 소유
MCE-011 external spec CloudEvents 1.0.2, id 속성 정의 id는 비어 있지 않은 String이며 형식 제약 없음 외부 표준. 저장소 밖 지식으로 명시 분리
EVD-272 command evidence/raw/272-schema-family-reachability.txt same 세 타입 소비자 0, membership 정적 검색
EVD-273 runtime probe evidence/raw/273-cloudevents-inbound-id-probe.txt same 세 id 형태의 실제 결과와 예외 타입 세 형태만. 저장소 소스 미수정
EVD-278 command ./gradlew :messaging:messaging-cloudevents:test --rerun-tasks same 7 / 0 / 0

A19-MESSAGING-CORE-API. messaging-core-api

분석 중에는 messaging/MESSAGING-CORE-API.md 파일이었다. 924줄.

messaging-core-api 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-core-api SSOT owner: messaging-core-api integration/family document: §A19 (secondary, INTEGRATION_ONLY)

성격. 정책 문서가 아니라 읽기 기록이다. 이 leaf가 무엇을 선언했고, 그 선언 중 무엇이 실제로 소비되며, 무엇이 소비되지 않는지를 source anchor와 함께 적는다. cycle 1의 family 문서(§A19)는 25개 leaf를 하나의 문서로 다뤘고 새 계약에서 secondary evidence로 강등됐다. 이 문서가 messaging-core-api의 canonical SSOT다.


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-core-api
  • canonical state analysisFile: §A19-MESSAGING-CORE-API
  • source path: src/messaging/messaging-core-api
  • leaf-owned subdocuments: 없음
  • related family/integration documents: §A19 (secondary)
  • registry allowed_dependencies: [] — 이 저장소에서 의존성이 하나도 없는 두 leaf 중 하나(다른 하나는 grpc-core-api)
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 85
production LOC 3,948
패키지 7
test 파일 8
test 메서드(실행 확인) 79
build/config 파일 build.gradle 1, gradle.lockfile 1
migration 0
외부 의존성 0

패키지 7개와 그 안의 타입 수:

패키지 타입 성격
api (root) 12 봉투와 그 안의 값 객체
api.header 5 헤더 이름·값·맵·예약 네임스페이스
api.destination 7 논리 목적지와 capability
api.publish 17 발행 요청·결과·증거
api.delivery 13 수신·핸들러 결과
api.settlement 5 수동 정산
api.error 26 실패 분류와 예외 계층
합계 85
Coverage ledger
scope/file group count disposition reason
src/main/java/**/api/*.java (root 12) 12 FULL_READ 전 파일 본문 확인
src/main/java/**/api/header/*.java 5 FULL_READ 전 파일 본문 확인
src/main/java/**/api/destination/*.java 7 FULL_READ 전 파일 본문 확인
src/main/java/**/api/publish/*.java 17 FULL_READ 전 파일 본문 확인
src/main/java/**/api/delivery/*.java 13 FULL_READ 전 파일 본문 확인
src/main/java/**/api/settlement/*.java 5 FULL_READ 전 파일 본문 확인
api/error/FailureCategory·FailureDescriptor·MessagingException 3 FULL_READ 전 파일 본문 확인
api/error/Message*Exception 나머지 23 STRUCTURAL_ONLY 전부 동일 형태 — 3개 생성자, 고정 CATEGORY 상수, retryable 리터럴. 시그니처·카테고리·retryable 값을 전수 대조했고 그 외 본문이 없다
src/test/java/** 8 FULL_READ 전 파일 본문 확인
build.gradle 1 FULL_READ 4줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일; 선언 의존성 0을 build.gradle에서 이미 확인
build/** EXCLUDED 빌드 산출물. source가 아니다

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

이 leaf는 브로커 중립 공개 계약을 소유한다. 여기에는 구현이 거의 없다 — 85개 타입 중 인터페이스 11개, enum 12개, record 46개, 유틸리티 final class 5개, 예외 26개이고, 실행 가능한 로직은 UuidV7.next(), WireSafeText.require, MessageHeaders.validateAndCopy, 그리고 record 생성자의 검증뿐이다.

무엇이 아닌가가 이 leaf에서는 무엇인가만큼 중요하고, 코드가 그것을 직접 말한다.

build.gradle 전문:

apply plugin: 'java-library'

dependencies {
}

src/main/java 전체에서 java.*와 자기 패키지 밖 import는 0개다(evidence/raw/269 §F). Spring도, Kafka·AMQP 클라이언트도, Reactor도 없다. 이것은 우연이 아니라 원래 계획이 명시한 제약이고(docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:13 — "messaging-core-api에는 Spring Kafka, Spring AMQP, Pulsar, NATS, Spring Message<?>, Reactor 의존성을 넣지 않는다"), 현재 소스에서 재측정해도 참이다.

경계는 세 방향으로 그어져 있다.

브로커 쪽으로. MessageDestination은 논리 이름·카탈로그 타입·payload 클래스만 갖고 topic/exchange/queue/subject를 갖지 않는다(destination/MessageDestination.java:9-11). DestinationName의 패턴 [a-z0-9][a-z0-9.-]{0,159}:/와 공백을 배제해서 topic://orders 같은 물리 주소를 논리 이름으로 밀어 넣는 것을 생성자에서 막는다(destination/DestinationName.java:16). 주석이 이유를 적는다 — "otherwise the physical mapping owned by the destination profile could be bypassed from application code."

프로그래밍 모델 쪽으로. 핵심 계약은 CompletionStage다. blocking facade(BlockingMessagePublisher)는 인터페이스만 여기 두고 구현을 다른 모듈로 밀어냈으며, Reactor facade는 아예 없다(publish/MessagePublisher.java:10-11).

애플리케이션 쪽으로. 이 경계는 이 leaf가 아니라 ArchUnit이 긋는다. CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM(CleanArchitectureTest.java:229-240)은 ..application.. 패키지가 dev.caskeleton.messaging..에 의존하는 것을 금지한다. 이유가 규칙 본문에 적혀 있다:

the application owns its publish port and outbox model; a bridge adapter translates, and the two outbox status models mean opposite things under the same names

이 규칙은 §12의 reachability 결과를 읽을 때 반드시 같이 봐야 한다. 이 leaf의 공개 타입 중 다수가 ..application..에서 참조 0인 것은 금지되어 있기 때문이지 잊혀서가 아니다.


2. 의존성과 런타임 배선

2.1 source 의존성

들어오는 것: 없음. registry allowed_dependencies: []이고 build.gradle에 선언이 없다.

나가는 것(이 leaf를 의존하는 messaging leaf, registry 기준): messaging-schema-api, messaging-schema-json, messaging-schema-avro, messaging-schema-protobuf, messaging-cloudevents, messaging-policy, messaging-transport-spi, messaging-runtime-core, messaging-observability, messaging-security, messaging-kafka, messaging-kafka-share-experimental, messaging-rabbit, messaging-reliability-api, messaging-outbox-jdbc-postgresql, messaging-inbox-jdbc-postgresql, messaging-claim-check, messaging-admin-api, messaging-admin-runtime, messaging-pulsar-experimental, messaging-nats-experimental, messaging-spring-cloud-stream-bridge, messaging-spring-boot-starter, messaging-testkit — messaging family의 나머지 24개 전부.

2.2 런타임 배선

runtime_memberships: ["app-bootstrap"]이고, 그 편입은 직접 선언이 아니라 **전이(transitive)**로 일어난다. src/app-bootstrap/build.gradle:87이 선언하는 것은 하나다:

implementation project(':messaging:messaging-spring-boot-starter')

starter의 allowed_dependencies가 17개 leaf를 끌고 오고 그 closure에 messaging-core-api가 있다. 즉 배포 아티팩트가 이 leaf를 싣는다. 실행 여부는 별개이고 master switch app.messaging.enabled(기본 false)가 결정한다(src/messaging/CLAUDE.md:56-57).

이 leaf 자체는 bean을 하나도 만들지 않는다. Spring stereotype·@Bean·@Conditional·@Profile 주석이 leaf 전체에 0개다(evidence/raw/269 §F, git grep exit=1). 따라서 §12.2의 conditional sibling 비교는 이 leaf에 적용 대상이 없다 — 비교할 sibling bean이 존재하지 않는다.


3. 패키지/컴포넌트 지도

3.1 api — 봉투와 값 객체 (12)

MessageEnvelope<T>가 중심이고 나머지 11개가 그 필드 타입이다.

MessageEnvelope<T>
├── MessageId            UUIDv7만 허용
├── MessageType          카탈로그 이름, 240 UTF-8 bytes
├── SchemaVersion        1 이상
├── producedAt           Instant
├── occurredAt           Optional<Instant>
├── ProducerId           서비스 이름, 120 bytes
├── CorrelationId        워크플로 상관값, 160 bytes
├── CausationId          → MessageId
├── ContentType          media type, 160자
├── partitionKey         Optional<String>, 1024 bytes
├── orderingKey          Optional<String>, 1024 bytes
├── TenantContext        [a-z0-9][a-z0-9._-]{0,63}
├── TraceContext         W3C traceparent/tracestate/baggage
├── MessageHeaders       ≤64개, ≤32,768 bytes
└── payload              T, non-null

부속: UuidV7(생성기), WireSafeText(검증 유틸).

봉투는 불변이고 네 가지 파생 메서드가 있다 — withPayload, withContentType, withTenant, withHeaders. 넷 다 messageId를 복사한다. withPayload의 javadoc이 그 이유를 적는다: "Encoding, decoding, Claim Check offloading, and DLQ forwarding all need this, and every one of them must keep messageId() intact — which is exactly what this method guarantees by construction"(MessageEnvelope.java:80-82).

3.2 api.header — 헤더 (5)

HeaderName, HeaderValue, MessageHeaders, ReservedHeaders, CanonicalEnvelopeHeaders.

ReservedHeaders는 23개 이름 상수와 msg. prefix 전체를 소유한다. CanonicalEnvelopeHeaders는 그 예약 네임스페이스를 둘로 쪼갠다 — 봉투 필드가 이미 갖고 있는 15개(ENVELOPE_FIELDS)와, 봉투에 대응 필드가 없어서 헤더로만 이동할 수 있는 나머지 8개(REDRIVE_ID, REDRIVE_COUNT, RETRY_ATTEMPT, FIRST_FAILURE_AT, LAST_FAILURE_AT, FAILURE_CATEGORY, FAILURE_CODE, ORIGIN_DESTINATION).

3.3 api.destination — 목적지 (7)

MessageDestination<T>, DestinationName, DestinationKind(7), MessagingCapabilities(boolean 12), DestinationCapabilities, ConfirmationRequirement(3), CapabilityRegistry.

3.4 api.publish — 발행 (17)

퍼블리셔 4종(MessagePublisher, BlockingMessagePublisher, BatchMessagePublisher, DelayedMessagePublisher), 요청 3종, 결과 5종, 증거 3종, enum 3종(PublishCompletion, ConfirmationLevel, RoutingOutcome, TransmissionEvidence — 4종), BrokerPosition.

3.5 api.delivery — 수신 (13)

MessageDelivery<T>, DeliveryMetadata, DeliveryContext, MessageHandler<T>, BatchMessageDelivery<T>, BatchDeliveryMetadata, BatchMessageHandler<T>, HandleResult(sealed, 4 변형), PauseResumeController, enum 4종.

3.6 api.settlement — 수동 정산 (5)

ManualMessageHandler<T>, SettlementController, SettlementResult, SettlementEvidence, SettlementCompletion.

3.7 api.error — 실패 (26)

FailureCategory(10), FailureDescriptor, MessagingException(abstract) + 구체 예외 23종.


4. 계약·불변식·상태 모델

이 leaf의 실질은 여기 있다. 표현할 수 없는 상태를 생성자에서 거절하는 것이 설계의 축이다.

4.1 발행 결과: 3상태와 12개 금지 조합

PublishCompletion은 boolean이 아니라 3상태다.

의미 호출자가 할 수 있는 것
CONFIRMED 요구 수준으로 브로커가 수락 완료
REJECTED 확실히 저장되지 않음 이 시도를 버려도 안전
AMBIGUOUS 브로커가 갖고 있을 수도 있음 같은 messageId로만 재발행

enum javadoc이 왜 셋인지 적는다: "Collapsing 'the broker refused this' and 'we never learned what the broker did' into one failure is what produces duplicate orders"(publish/PublishCompletion.java:6-8).

PublishResult 생성자(publish/PublishResult.java:39-101)가 거절하는 조합 12가지:

# 거절 조건 이유(코드/주석 기준)
1 attempts < 1 첫 시도가 1
2 elapsed < 0
3 CONFIRMED + !brokerAccepted 확인은 브로커 수락을 전제
4 CONFIRMED + confirmationLevel == NONE 확인 수준 없는 확인은 확인이 아님
5 CONFIRMED + UNROUTABLE 라우팅 실패를 성공으로 읽히게 함
6 AMBIGUOUS + confirmationLevel != NONE 모호한데 확인을 주장
7 AMBIGUOUS + brokerAccepted 같은 이유
8 AMBIGUOUS + NOT_TRANSMITTED 나가지 않은 것은 모호가 아니라 거절
9 !CONFIRMED + failure.isEmpty() 실패 서술 없는 실패
10 CONFIRMED + failure.isPresent() 성공에 실패 서술
11 REJECTED + brokerAccepted "한 주문이 둘이 되는 조합"
12 CONFIRMED + UNKNOWN routing 확인해 준 응답이 라우팅도 말한다
13 AMBIGUOUS + ROUTED 라우팅을 보고한 브로커는 답한 것
14 position.isPresent() + NOT_TRANSMITTED 나가지 않은 메시지의 좌표는 남의 것

11번과 14번에는 코드 주석이 직접 달려 있다.

if (completion == PublishCompletion.REJECTED && evidence.brokerAccepted()) {
  // A broker that acknowledged the message did not reject it. Left representable, this is the
  // combination that turns a delivered message into one the caller re-publishes as if it had
  // never been sent.
  throw new IllegalArgumentException("rejected publish cannot claim broker acceptance");
}

record가 public이고 모든 adapter가 이것을 만들기 때문에 호출부를 믿지 않고 여기서 검증한다는 것도 javadoc에 적혀 있다(PublishResult.java:18-20).

4.2 증거는 결론보다 먼저 기록된다

PublishEvidence(publish/PublishEvidence.java)는 queuedLocally, transmission, brokerAccepted, confirmationLevel 넷을 갖고, javadoc이 순서를 못 박는다 — "Evidence is recorded before a completion is chosen, not derived from it. That ordering is what lets an operator answer 'could the broker be holding this message?' from a stored result."

TransmissionEvidence가 3상태(NOT_TRANSMITTED / MAY_HAVE_BEEN_TRANSMITTED / TRANSMITTED)인 것이 그 순서를 가능하게 한다.

4.3 정산: 같은 3상태 규율

SettlementResult(settlement/SettlementResult.java:23-36)도 같은 형태다.

  • SETTLED인데 !brokerConfirmed → 거절
  • SETTLED인데 redeliveryPossible → 거절
  • !SETTLED인데 failure.isEmpty() → 거절

SettlementEvidencebrokerConfirmed && !transmitted를 거절한다. javadoc: "Treating an unconfirmed acknowledgement as settled is the classic route to a message that looks processed in logs and is processed again minutes later."

4.4 없는 것으로 말하는 계약

세 enum이 일부러 비어 있는 자리를 갖는다.

enum 없는 값 코드가 적은 이유
DeliveryGuarantee EXACTLY_ONCE "No broker delivers exactly-once across an external side effect... Naming a guarantee the platform cannot honour would push that responsibility out of sight, so the enum stops where the evidence stops."
OrderingScope GLOBAL "Ordering is a property of a partition, a key mapping, or a single consumer — never of a whole destination."
PublishOptions 자유형 hint map "One existed for a native surface that does not read it... an escape hatch around destination policy that never opened."

이 셋은 테스트로 붙들려 있다 — CoreValueTypesTest.guaranteeEnumsDoNotAdvertiseUnsupportedSemanticsvalues()EXACTLY_ONCEGLOBAL이 없음을 단언한다(CoreValueTypesTest.java:25-29). 이름이 다시 추가되면 테스트가 깨진다.

4.5 wire 안전성: 한 곳에 모은 규칙

WireSafeText(WireSafeText.java)가 두 가지를 한다.

public static void requireNoControls(String value, String what) {
  for (int index = 0; index < value.length(); index++) {
    char character = value.charAt(index);
    if (character < 0x20 || character == 0x7F) { throw ... }
  }
}
  • 바이트로 센다. javadoc: "A char count bounds nothing on a wire: a 240-character string is up to 960 UTF-8 bytes."
  • 제어문자를 정제하지 않고 거절한다. "Silently stripping a CR turns a caller's two-line value into a one-line value that no longer means what they wrote, and the caller never learns."
  • 탭도 거절한다. HTTP 필드 값에서는 합법이지만 "a header carried over a line-folding binding and the same header carried over a length-prefixed one disagree about whether a tab ends the value."

호출자: CorrelationId(160), MessageType(240), ProducerId(120), HeaderValue(4096), MessageEnvelope의 partitionKey/orderingKey(1024), TraceContext.baggage.

HeaderNameWireSafeText를 쓰지 않고 자체 정규식 [a-zA-Z0-9!#$%&'*+._|~-]+(HTTP token)을 쓴다. 더 엄격하다 — 공백·콜론·비ASCII를 전부 배제한다. 그리고 trim하지 않고 선행/후행 공백을 거절한다. 주석이 이유를 적는다:

if (!value.equals(value.strip())) {
  // Trimming would mean `Authorization ` and `Authorization` are the same name to the
  // denylist and different names on the wire, which is precisely how the check was bypassed.
4.6 자격증명 헤더 차단: 정확 일치 → 세그먼트 매칭

MessageHeaders.carriesACredential(header/MessageHeaders.java:142-160)은 두 단계다.

  1. SECRET_NAMES 9개 정확 일치(authorization, cookie, access_token, …)
  2. SECRET_SEGMENTS 10개를 [._\-]+로 쪼갠 세그먼트 단위로 검사, 그리고 인접 세그먼트를 붙여서 한 번 더 검사
// Adjacent segments are also tested joined, because the same word is written both ways:
// `api_key` is one segment to a reader and two to a splitter, and `x-api-key` is two of
// three. Joining only neighbouring pairs is what keeps `routing-key` accepted.

두 방향 다 테스트가 있다. x-api-key·auth-token·db_password·request.signature·Cookie는 거절되고(WireBoundaryRejectionTest.java:166-175), tokenizer-version·secretariat-id는 통과한다(:177-188). 부분문자열 매칭이었으면 후자가 오탐이 된다.

거절 메시지는 이름만 담고 값은 절대 담지 않는다. 주석: "an error message is written to a log that is exactly as readable as the broker storage this check exists to keep the value out of."

4.7 예약 네임스페이스: 이름 목록 → prefix 소유

ReservedHeaders.isReserved(header/ReservedHeaders.java:135-141)는 23개 이름 집합 또는 msg. prefix로 판정한다.

// The check used to be exact membership of NAMES, so `msg.anything` that this
// release has not defined was an ordinary application header — until a later release defined it,
// at which point every application already writing it silently started overwriting envelope
// metadata. Owning the prefix means a new platform header is a compatible change.

테스트가 이 성질을 직접 붙든다 — ReservedHeaders.isReserved("msg.not-defined-in-this-release")true이고, 애플리케이션이 msg.not-defined-yet을 쓰면 거절되며, platform factory는 여전히 쓸 수 있다(WireBoundaryRejectionTest.java:190-207).

4.8 MessageHeaders의 두 factory
factory 예약 이름 자격증명 이름 호출자
application(Map) 거절 거절 업무 코드
platform(Map) 허용 거절 wire에서 봉투를 복원하는 adapter

자격증명은 양쪽 다 거절이다. javadoc: "a credential that reaches a header ends up in broker storage, DLQ dumps, and operator tooling, and no downstream redaction can undo that."

4.9 MessageId: 타입 이름과 실제 검증의 정렬
if (value.version() != VERSION_7) {
  throw new IllegalArgumentException(
      "a message identity is UUIDv7 (time-ordered); this is version " + value.version());
}
if (value.variant() != 2) {
  throw new IllegalArgumentException("a message identity must use the RFC 4122 variant");
}

주석이 왜 이 검증이 생겼는지 적는다: "The type says UUIDv7 and the constructor accepted any UUID, including v4 and the nil UUID. Version 7 is what makes the identity time-ordered, which is what the outbox index and every 'oldest first' claim depend on; a v4 stored in the same column silently defeats both."

테스트가 그 문장을 그대로 단언한다 — new MessageId(UUID.randomUUID())는 거절되고 이유 문자열에 UUIDv7이 포함된다(WireSafeValueObjectTest.java:73-80, as("a v4 in the same column defeats every 'oldest first' claim the outbox makes")).

주의. 이것은 이 leaf의 MessageId에만 해당한다. 저장소의 다른 UUIDv7 구현들은 별개이고 §12.3에서 다룬다.

4.10 UuidV7: 밀리초 내 단조성

UuidV7.advance(UuidV7.java:54-61)는 48비트 타임스탬프와 12비트 카운터를 하나의 AtomicLong에 packing하고 updateAndGet으로 CAS 루프를 돈다.

private static long advance(long previous) {
  long now = System.currentTimeMillis();
  long previousTimestamp = previous >>> COUNTER_BITS;
  if (now > previousTimestamp) {
    return now << COUNTER_BITS;
  }
  return previous + 1;
}

RFC 9562의 rand_a 12비트를 난수가 아니라 밀리초 내 단조 카운터로 쓴다. 시계가 뒤로 가도 previous + 1이므로 중복이나 역행이 나오지 않고 "미래에서 빌려올" 뿐이다. 카운터가 넘치면 타임스탬프 필드로 자연히 carry된다.

이 성질은 CoreValueTypesTest.newMessageIdIsVersionSevenAndTimeOrdered가 두 연속 호출의 compareTo가 음수임을 단언해서 붙든다. 다만 단일 스레드 2회 호출이므로 경합 하 단조성은 이 테스트가 증명하지 않는다(§16 참조).

4.11 TraceContext: 표준을 실제로 검사한다

세 값이 전부 Optional<String>이고 non-null 검사만 있던 시절의 기록이 javadoc에 남아 있다 — "which made this record a general-purpose string carrier wearing the name of a standard."

현재 검사:

필드 규칙
traceparent [0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2} 정확 일치, ff 버전 거절, all-zero trace id 거절, all-zero span id 거절
tracestate ≤512 bytes, ≤32 list member, 각 member가 key=value 또는 tenant@vendor=value 문법, 빈 member는 허용(전방호환)
baggage ≤8,192 bytes, ≤64 member, 제어문자 없음, 각 member key=value
조합 tracestate가 있는데 traceparent가 없으면 거절

대문자 hex를 접는 대신 거절하는 이유도 적혀 있다: "the standard defines the field as lowercase, and a receiver comparing trace IDs as strings — which collectors do — would treat the two cases as two different traces."

tracestate 단독 거절 이유: "vendor state belonging to no trace. Propagating it hands the next hop a key it will attribute to whatever trace that hop starts."

7개 무효 traceparent가 파라미터 테스트로 전부 커버된다(WireBoundaryRejectionTest.java:71-90).

4.12 실패 분류와 기본 재시도 정책

FailureCategory 10개, FailureDescriptor.defaultRetryable(error/FailureDescriptor.java:67-79)이 그 중 3개만 재시도 가능으로 본다.

retryable = true retryable = false
TRANSIENT_INFRASTRUCTURE PERMANENT_BUSINESS, POISON_MESSAGE, DESERIALIZATION, AUTHENTICATION, AUTHORIZATION, AMBIGUOUS, CONFIGURATION
THROTTLED
PROCESSING_TRANSIENT

AMBIGUOUS가 false인 것은 모순이 아니라 설계다. 모호한 발행은 자동 재시도 대상이 아니고, 호출자가 같은 messageId로 재발행할지를 결정한다(MessagePublishAmbiguousException javadoc).

FailureDescriptor는 DLQ까지 이동하므로 payload·스택트레이스·자격증명·실제 메시지 키를 담지 않고, sanitizedMessage는 512자에서 잘린다(거절이 아니라 절단). javadoc: "Stack traces belong in secure log storage; a DLQ is read by more people than the log is."

4.13 HandleResult: sealed 4변형

Success / Retry(FailureDescriptor) / DeadLetter(FailureDescriptor) / Reject(FailureDescriptor). 어떤 변형도 브로커 ack 핸들을 갖지 않는다. javadoc: "The handler states an intent; the platform performs the settlement."

ConsumerContractTest.handleResultPermitsExactlyTheFourDeclaredOutcomesgetPermittedSubclasses()로 이 집합을 고정한다.

4.14 배치는 트랜잭션이 아니다

BatchPublishResult는 항목별 결과를 제출 인덱스와 함께 보존하고 배치 수준 boolean으로 접지 않는다. BatchPublishOptions에는 retry 설정이 없다. javadoc: "retrying the batch would resubmit entries that already confirmed."

BatchDeliveryMetadata.isSafeForOrderedDestination()orderingUnit.isPresent()다 — 두 파티션에서 끌어온 배치는 순서 보장 목적지에 넘길 수 없다.


5. 주요 실행 경로

이 leaf에는 실행 경로가 거의 없다. 실제로 코드가 도는 지점은 넷이다.

  1. 봉투 생성new MessageEnvelope<>(...) → 14개 non-null 검사 + partitionKey/orderingKey wire 검사
  2. 헤더 생성MessageHeaders.application/platform(Map) → 개수(≤64) → 이름별 예약/자격증명/중복 검사 → 총 바이트(≤32,768)
  3. 식별자 생성MessageId.newId()UuidV7.next()AtomicLong.updateAndGet(advance)
  4. 결과 조립new PublishResult(...) / new SettlementResult(...) → 조합 검증

나머지는 전부 인터페이스 선언이고, 구현은 messaging-runtime-core·messaging-kafka·messaging-rabbit 등 다른 leaf가 소유한다.


6. 실패 경로와 복구/번역

6.1 계층

MessagingException(abstract) → 23개 구체 예외. 기반 타입이 FailureDescriptor를 갖고 category()·retryable()를 위임한다. javadoc이 목적을 적는다 — "a caller catching the base type can still classify and route the failure without matching on exception classes."

6.2 23개 예외의 카테고리·재시도 전수표
예외 category retryable leaf 밖 참조
MessageAuthenticationException AUTHENTICATION false 0
MessageAuthorizationException AUTHORIZATION false 16
MessageBackpressureException TRANSIENT_INFRASTRUCTURE true 4
MessageBrokerUnavailableException TRANSIENT_INFRASTRUCTURE true 0
MessageConsumerException PROCESSING_TRANSIENT true 0
MessageDeadLetterException TRANSIENT_INFRASTRUCTURE true 0
MessageHandlerTimeoutException PROCESSING_TRANSIENT true 0
MessageHeaderRejectedException PERMANENT_BUSINESS false 0
MessagePublishAmbiguousException AMBIGUOUS false 0
MessagePublishRejectedException PERMANENT_BUSINESS false 0
MessagePublishTimeoutException AMBIGUOUS false 2
MessageRedriveException TRANSIENT_INFRASTRUCTURE true 0
MessageRetryExhaustedException PERMANENT_BUSINESS false 0
MessageRoutingException PERMANENT_BUSINESS false 0
MessageSchemaIncompatibleException DESERIALIZATION false 4
MessageSerializationException DESERIALIZATION false 8
MessageSettlementException TRANSIENT_INFRASTRUCTURE true 1
MessageSettlementUnknownException AMBIGUOUS false 0
MessageTooLargeException PERMANENT_BUSINESS false 17
MessageTopologyException CONFIGURATION false 2
MessageValidationException PERMANENT_BUSINESS false 16
MessagingCapabilityUnavailableException CONFIGURATION false 13
MessagingConfigurationException CONFIGURATION false 59

23개 중 12개가 leaf 밖에서 한 번도 참조되지 않는다(evidence/raw/269 §B, 12개 전부 git grep exit=1). §12.1에서 다룬다.

6.3 조용한 성능 저하를 막는 설계

MessagingCapabilityUnavailableException javadoc: "Downgrading replication evidence to a bare ack, or ordered delivery to unordered, produces a system that looks healthy right up to the moment the guarantee actually mattered."

MessageBackpressureException javadoc: "Blocking the caller until a slot frees turns producer-side saturation into thread exhaustion in the calling application, which is a far worse failure than a fast rejection." 그리고 "Nothing was transmitted when this is thrown, so the message has no ambiguity."


7. 트랜잭션·동시성·수명주기

트랜잭션 개념이 이 leaf에는 두 가지 형태로만 등장하고 둘 다 선언이다.

  • MessagingCapabilities.brokerTransaction — 브로커가 트랜잭션 스코프를 제공하는가
  • ProcessingGuarantee.BROKER_TRANSACTIONAL — "Atomicity holds only inside the transaction scope the broker itself defines"
  • ExternalSideEffectGuarantee.INBOX_TRANSACTIONAL — "An Inbox row and the side effect commit inside the same database transaction"

DB 트랜잭션은 이 leaf가 만지지 않는다.

동시성 지점은 하나다: UuidV7.STATE(AtomicLong). updateAndGet이 CAS 루프이므로 다중 스레드에서도 각 호출이 서로 다른 packed state를 얻는다. RANDOM(SecureRandom)은 thread-safe다.

MessageHeaders는 생성 시 LinkedHashMap에 복사하고 Collections.unmodifiableMap으로 감싸 반환하므로 공유 안전하다. 다만 find(String)values.entrySet().stream() 선형 탐색이다 — 최대 64개이므로 실용상 문제는 아니지만 hot path에서 반복 호출되면 O(n)이다.

수명주기 개념은 DeliveryContext.shutdownRequested뿐이고, javadoc이 목적을 적는다 — "during a graceful drain the platform stops creating new retry attempts, and a long-running handler that can wind down early shortens the drain instead of being cancelled at the deadline." 이 필드는 production에서 도달 불가능하다(§12.1).


8. 설정·기능 플래그·환경 차이

이 leaf에는 설정이 없다. properties·yaml·환경변수·시스템 프로퍼티를 읽는 코드가 0이다. 모든 값은 컴파일 타임 상수다.

경계값 전수:

상수 위치
ContentType.MAX_LENGTH 160자 ContentType.java:12
CorrelationId.MAX_BYTES 160 CorrelationId.java:16
MessageType.MAX_BYTES 240 MessageType.java:13
ProducerId.MAX_BYTES 120 ProducerId.java:13
MessageEnvelope.MAX_KEY_BYTES 1,024 MessageEnvelope.java:75
HeaderName.MAX_BYTES 128 HeaderName.java:16
HeaderValue.MAX_BYTES 4,096 HeaderValue.java:18
MessageHeaders.MAX_COUNT 64 MessageHeaders.java:22
MessageHeaders.MAX_TOTAL_BYTES 32,768 MessageHeaders.java:23
TenantContext 패턴 [a-z0-9][a-z0-9._-]{0,63} TenantContext.java:16
DestinationName 패턴 [a-z0-9][a-z0-9.-]{0,159} DestinationName.java:16
TraceContext.MAX_TRACESTATE_BYTES 512 TraceContext.java:53
TraceContext.MAX_TRACESTATE_MEMBERS 32 TraceContext.java:51
TraceContext.MAX_BAGGAGE_BYTES 8,192 TraceContext.java:56
TraceContext.MAX_BAGGAGE_MEMBERS 64 TraceContext.java:58
FailureDescriptor.MAX_MESSAGE_LENGTH 512자(절단) FailureDescriptor.java:26
FailureDescriptor.MAX_CODE_LENGTH 120자(거절) FailureDescriptor.java:27
PublishOptions.DEFAULT_TIMEOUT 5초 PublishOptions.java:25
BatchPublishOptions.DEFAULT_TIMEOUT 30초 BatchPublishOptions.java:21
BatchPublishOptions.DEFAULT_MAX_BATCH_SIZE 500 BatchPublishOptions.java:22

PublishOptions.defaults()가 요구하는 확인 수준은 REPLICATION_OR_PERSISTENCE_ACK다 — 기본값이 가장 강한 보장이고, 약하게 쓰려면 명시해야 한다.

단위가 섞인 곳이 하나 있다. ContentType문자 160, 다른 문자열 값 객체는 바이트다. ContentType은 미디어 타입 정규식이 ASCII만 허용하므로 실질 차이가 없지만, 이 leaf에서 유일하게 WireSafeText를 쓰지 않는 문자열 값이다.


9. 퍼시스턴스/외부 시스템 세부

없다. 이 leaf는 DB·브로커·파일시스템·네트워크를 만지지 않는다. SecureRandom(엔트로피)과 System.currentTimeMillis()(시계)가 유일한 외부 접촉이고 둘 다 UuidV7 안에 있다.


10. 테스트 레인과 실제 증명 범위

레인은 하나다: ./gradlew :messaging:messaging-core-api:test. 실행 결과 BUILD SUCCESSFUL, 79 tests, 0 skipped, 0 failures (--rerun-tasks, revision 21234e38).

테스트 클래스 무엇을 실제로 증명하는가 무엇을 증명하지 않는가
CoreValueTypesTest 7 값 객체 거절 조건, MessageId v7/variant 2, 연속 2회 시간순, EXACTLY_ONCE/GLOBAL 부재 경합 하 UuidV7 단조성
MessageEnvelopeTest 11 예약/비밀 헤더 거절(대소문자 무관), platform factory의 예약 쓰기 허용, 개수·바이트·이름·값 상한, withPayload의 identity 보존 실제 브로커가 이 값을 받아들이는지
WireSafeValueObjectTest 7 헤더 이름 CRLF·NUL·콜론·후행공백 거절, 메시지 타입 개행 거절, 바이트 경계, v4 거절
WireBoundaryRejectionTest 27 제어문자 6종 파라미터화, 바이트 경계, traceparent 무효 7종, tracestate/baggage 경계, 자격증명 이름 5종 거절 + 오탐 2종 통과, msg. prefix 소유 실제 collector/브로커 동작
ConsumerContractTest 8 HandleResult 4변형 고정, attempt 1 규칙, redelivered 모순 거절, SETTLED 불변식, DeliveryContext.isExpired 경계 production이 DeliveryContext를 만드는지
DestinationCapabilityTest 5 논리 이름에 브로커 주소 불가, 대문자 거절, MessagingCapabilities.none(), DestinationKind 7종 고정 capability 선언이 실제 브로커와 맞는지
PublishResultTest 13 §4.1의 금지 조합 중 9가지를 직접 단언 실제 adapter가 이 조합을 만들지 않는지
ModuleSmokeTest 1 패키지 이름 사실상 아무것도

이 레인이 증명하는 것의 성격. 전부 new로 값을 만들고 예외를 기대하는 순수 단위 테스트다. 브로커도, Spring 컨텍스트도, 네트워크도 없다. 그래서 "계약이 자기 자신과 모순되지 않는다"는 증명되고, "adapter가 이 계약을 지킨다"는 증명되지 않는다. 후자는 messaging-kafka·messaging-rabbit의 contract harness가 소유하고 이 leaf 밖이다.

ConsumerContractTest.deliveryContextReportsHandlerDeadlineExpiry가 특히 그렇다 — 경계 동작은 정확히 검증되지만, §12.1이 보이듯 production 코드는 DeliveryContext를 만들지 않으므로 그 검증이 실행 경로를 보호하고 있지는 않다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 위치 이 leaf에 대해 실제로 무엇을 막는가 실패 지점
registry fail-closed src/config/architecture/modules.json + ca.architecture-registry.settings.gradle 등록되지 않은 leaf는 settings에 포함되지 않음 Gradle configuration
verifyCleanArchitectureDependencies src/build.gradle 실제 project 의존 edge를 allowed_dependencies: []와 대조 — 이 leaf에 의존성을 하나라도 추가하면 실패 Gradle task
verifyRuntimeModuleMembership src/build.gradle 코드만 추가해서 런타임에 들어가는 것을 막음. registry를 먼저 고쳐야 함 Gradle task
APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM CleanArchitectureTest.java:229 ..application..dev.caskeleton.messaging..을 참조하는 것을 금지 ArchUnit
checkstyle / spotbugs convention plugin build/reports/{checkstyle,spotbugs} 생성 확인 Gradle

이 leaf에 직접 걸리는 messaging 전용 ArchUnit 규칙은 없다. MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS(CleanArchitectureTest.java:2068)는 ..adapter.outbound.messaging..을 대상으로 하고 이 leaf(dev.caskeleton.messaging.api)가 아니다.

src/build.gradle:65-110messagingVerificationSkeletons(9개 verifyMessaging* task)는 전부 app-bootstrap/build/messaging-evidence/**/manifest.json을 요구하는 fail-closed 자격 게이트이고, 이 leaf의 산출물을 요구하지 않는다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/269-messaging-core-api-reachability.txt, evidence/raw/270-messaging-runtime-membership-doc-drift.txt.

검색 명령(전부 revision 21234e38에서 실행):

git grep -n -w '<PublicType>' -- src ':!src/messaging/messaging-core-api'

git grep은 무매치에서 exit 1을 반환하므로, 아래의 "0"은 전부 exit 1로 확인한 값이다.

12.1 Public surface reachability

85개 타입 중 leaf 밖 참조가 0인 것은 21개다. 성격이 다른 세 묶음으로 나뉜다.

(a) 내부 헬퍼 — 문제 없음 (1)

WireSafeText. 이 leaf의 값 객체들이 내부적으로만 부른다. public인 것은 패키지가 나뉘어 있어서다.

(b) 소비자 없는 예외 어휘 (12)

MessageAuthenticationException, MessageBrokerUnavailableException, MessageConsumerException, MessageDeadLetterException, MessageHandlerTimeoutException, MessageHeaderRejectedException, MessagePublishAmbiguousException, MessagePublishRejectedException, MessageRedriveException, MessageRetryExhaustedException, MessageRoutingException, MessageSettlementUnknownException.

기반 타입 MessagingException은 살아 있다 — leaf 밖 3곳이 쓴다:

  • DefaultMessagingAdminService.java:223instanceof로 분류
  • ClaimCheckIntegrityException.java:20extends
  • PublishResults.java:37instanceof로 sanitized descriptor 추출

계층은 쓰이고 잎은 쓰이지 않는다. 특히 MessagePublishAmbiguousException은 이 설계 전체의 중심 개념(AMBIGUOUS)에 이름을 준 타입인데 아무도 던지지 않는다. adapter들은 예외 대신 PublishResult를 반환하는 경로를 쓰고(§6.2에서 MessagingConfigurationException 59회, MessageTooLargeException 17회처럼 실제로 쓰이는 것들은 대부분 설정/검증 계열이다), 발행·정산의 실패는 결과 record로 흐른다.

(c) 소비자 없는 consumer-side 계약 (8)

타입 선언된 역할 leaf 밖 참조
MessageHandler<T> "The M1 typed handler implemented by ordinary business code" 0
BatchMessageHandler<T> "The M2 batch consume entry point" 0
BatchMessageDelivery<T> 배치 핸들러에 넘겨지는 배치 0
ManualMessageHandler<T> "The M2 handler that settles its own deliveries" 0
PauseResumeController "The M2 consumer flow-control entry point" 0
ProcessingGuarantee 중복 처리 무력화 방식 0
DelayedMessagePublisher "The M2 scheduled-delivery entry point" 0
CapabilityRegistry 목적지별 capability 해석 0

이 중 MessageHandler<T>가 가장 무겁다. 선언된 핸들러 계약과 실제로 배선된 핸들러 계약이 다르다.

messaging-core-api가 선언하는 것:

// delivery/MessageHandler.java:14-22
public interface MessageHandler<T> {
  CompletionStage<HandleResult> handle(MessageDelivery<T> delivery);
}

MessageDelivery<T>MessageEnvelope<T> + DeliveryMetadata + DeliveryContext를 묶는다.

핸들러 결과를 정산으로 바꾸는 유일한 지점(messaging-runtime-core.DefaultDeliveryProcessor)이 실제로 받는 것:

// DefaultDeliveryProcessor.java:40, 47
private final Function<MessageEnvelope<EncodedMessage>, HandleResult> handler;

세 가지가 다르다.

  1. 동기다. CompletionStage가 아니라 Function이므로 핸들러가 비동기일 수 없다.
  2. MessageDelivery가 없다. 봉투만 받는다. 따라서 DeliveryMetadata.deliveryAttempt(몇 번째 시도인가)와 redelivered가 핸들러에 도달하지 않는다.
  3. DeliveryContext가 없다. handlerDeadline·isExpired(now)·shutdownRequested가 도달하지 않는다.

세 번째는 독립적으로도 확인된다. DeliveryContext의 leaf 밖 참조 4건은 전부 테스트 파일이다 — KafkaContractHarness.java:7,216DeadLetterOrchestratorTest.java:12,226. production 소스에서 DeliveryContext를 만드는 코드는 저장소에 없다. DeliveryContext의 javadoc이 설명하는 graceful drain 협력("a long-running handler that can wind down early shortens the drain")은 현재 배선으로는 일어날 수 없다.

한편 MessageDeliveryDeliveryMetadata는 production에서 쓰인다 — 다만 핸들러에 넘기기 위해서가 아니라 DLQ·retry 경로에서 쓰인다:

  • MessageDelivery: KafkaDeadLetterPublisher:44, KafkaRetryExecutor:72, KafkaRetryTopicPublisher:59, RabbitDeadLetterPublisher:67, DeadLetterOrchestrator:69, TransactionalInboxHandler:49
  • DeliveryMetadata: KafkaDeliveryMapper:105, RabbitDeliveryMapper:107, policy/RetryContext:23, transport-spi/TransportDelivery:21

그리고 핸들러 계약은 저장소에 이 있다:

인터페이스 소유 leaf 시그니처 구현체
MessageHandler<T> messaging-core-api CompletionStage<HandleResult> handle(MessageDelivery<T>) 없음
IdempotentMessageHandler<T> messaging-reliability-api CompletionStage<HandleResult> handleOnce(String, MessageDelivery<T>, TransactionalMessageAction<T>) TransactionalInboxHandler
(익명) Function<MessageEnvelope<EncodedMessage>, HandleResult> messaging-runtime-core 동기, 봉투만 생성자 인자

(d) 배치 경로: 만들어진 metadata를 받을 곳이 없다

BatchDeliveryMetadata는 leaf 밖 참조가 있다(0이 아니다). 두 registrar가 만든다:

  • KafkaBatchConsumerRegistrar.java:104metadataFor(partition, slice, now)
  • RabbitBatchConsumerRegistrar.java:139release(now)

그리고 둘 다 자기 브로커 전용 record에 담는다(PartitionBatch, AmqpBatch). 두 record의 javadoc이 같은 문장을 쓴다:

 * @param metadata the batch-wide metadata handed to the handler

그런데 new BatchMessageDelivery는 저장소 전체에서 0건이고(git grep exit=1), BatchMessageHandler를 구현하거나 참조하는 코드도 0건이다. 즉 두 registrar는 배치 metadata를 정확히 계산해서(Kafka는 파티션 단위라 settlableAsBatch=true, Rabbit은 multiple-ack이 in-flight까지 정산하므로 false) 브로커별 record에 넣고, javadoc이 말하는 handler로의 전달은 존재하지 않는다.

(e) 한계

git grep 기반 정적 검색이므로 다음을 덮지 못한다: 리플렉션 조회, ServiceLoader, 애노테이션 프로세서 생성 코드, 문자열로 조립한 클래스 이름, 이 저장소 밖의 소비자. 다만 이 leaf에는 애노테이션이 0개이고 META-INF/services도 없으며(find 결과 resources 디렉터리 자체가 없다 — Gradle이 processResources NO-SOURCE를 보고한다), 이 저장소는 라이브러리 배포 저장소가 아니라 템플릿이므로 "저장소 밖 소비자"가 유일하게 남는 가능성이다. §17에서 그 갈래를 다룬다.

12.2 Conditional sibling comparison

적용 대상 없음. 이 leaf에는 Spring stereotype·@Bean·@Conditional·@Profile이 0개이고(git grep exit=1), bean을 하나도 만들지 않는다. 비교할 sibling이 존재하지 않는다.

이 leaf의 활성화 비대칭은 다른 축에서 일어난다 — registry runtime_memberships. §12.4 참조.

12.3 Duplicate mechanism sweep

(a) UUIDv7 생성기

저장소에 UUIDv7을 다루는 production 구현이 여럿이다.

위치 성격
messaging-core-api/.../api/UuidV7.java 이 leaf. AtomicLong packing, 밀리초 내 단조 카운터
adapter/outbound/notification/.../dispatch/UuidV7Generator.java notification 플랫폼 전용
adapter/outbound/persistence-jpa/src/testkit/.../id/UuidV7Generator.java testkit source set
adapter/outbound/persistence-mongo/.../mapping/DomainDocumentId.java Mongo 문서 id
application-core/.../notification/platform/api/NotificationId.java 애플리케이션 식별자
sample-portfolio/.../identifier/Uuid*Factory.java (3종) 샘플

messaging-core-api.UuidV7의 leaf 밖 참조는 1건이다(messaging-testkit의 JMH 벤치마크). 즉 messaging 밖에서는 아무도 이 구현을 쓰지 않고 각자 만들었다.

이것이 자동으로 결함은 아니다 — 모듈 경계가 의존을 금지하는 구조(APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM)에서는 중복이 의도된 비용일 수 있다. 다만 §4.10의 밀리초 내 단조성 같은 성질이 구현마다 같은지는 이 leaf가 답할 수 없고, family 밖이므로 cross-scope가 소유한다.

(b) wire-safe 텍스트 검증

WireSafeText의 leaf 밖 참조는 0이다. 그런데 제어문자·인코딩 경계를 각자 검사하는 곳이 저장소에 최소 15개 있다 — web/conditional/EntityTag, web/http/WebUriPolicy, cache-redis/.../codec/RedisEnvelope, fileserver/FileserverControlRecordCodec, mongo/changestream/MongoChangeEventIdentity, application-core/cache/CacheRefreshOwnerToken, application-core/objectstorage/model/ObjectMediaType, grpc-core-api/core/GrpcIdentifiers, shared-contract/ratelimit/EdgeRateLimitSubject 등.

WireSafeText의 javadoc은 그 존재 이유를 "Each copy of this check that lived in its own record was one more place for the rule to drift"라고 적는데, 그 통합은 이 leaf 안에서만 일어났다. 저장소 수준에서는 여전히 각자 검사한다. 다시 말해 규칙은 옳게 진술됐고 적용 범위가 leaf 경계에서 멈춘다.

(c) 헤더 네임스페이스

msg. 리터럴을 이 leaf 밖에서 쓰는 production 코드는 1곳뿐이다 — messaging-observability/.../MessagingRedactor.java:24"msg.id"를 문자열 리터럴로 갖는다. 나머지 매치는 Kafka 테스트다. 상수(ReservedHeaders.MESSAGE_ID)가 있는데 리터럴을 쓴 것이므로, 상수가 바뀌면 redactor가 조용히 어긋난다. 작지만 실재하는 drift 표면이다.

12.4 Documentation / measured-count drift

확인된 drift 1건. 원시 증거 evidence/raw/270-messaging-runtime-membership-doc-drift.txt.

docs/messaging/support-matrix.md:23-24가 이렇게 말한다:

또한 registry의 messaging leaf는 모두 runtime_memberships가 비어 있다. 이는 build-only / incubating — 어느 composition root에도 편입되지 않았다는 뜻이며…

현재 revision에서 registry를 다시 세면:

messaging leaves          : 25
runtime_memberships empty : 7
runtime_memberships wired : 18

messaging-core-api 자신이 wired 18개에 포함된다. 즉 이 문장은 이 leaf에 대해 직접 틀렸다.

같은 문단의 마지막 문장은 "자세한 규칙은 src/messaging/CLAUDE.md가 소유한다"고 가리키는데, 그 파일은 이미 정정을 기록해 두었다(src/messaging/CLAUDE.md:46-59):

이 절은 한동안 사실이 아닌 채로 남아 있었다. "registry의 모든 messaging leaf는 runtime_memberships가 비어 있고 따라서 build-only"라고 쓰여 있었는데, 다섯 어댑터 remediation이 messaging-spring-boot-starterapp-bootstrap 의존성으로 넣으면서 그 closure 전체가 런타임 classpath에 올라갔다. 정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다.

그래서 이것은 단순한 오래된 문서가 아니다. 같은 저장소의 두 문서가 같은 revision에서 서로 모순되고, 틀린 쪽이 옳은 쪽을 권위로 지목하고 있다. 그리고 틀린 쪽이 운영자가 읽는 지원 매트릭스다. CLAUDE.md가 도달한 결론("세는 순간 다시 drift한다")이 정확히 support-matrix에는 적용되지 않았다.

영향 방향이 중요하다 — 문서는 실제보다 약하게 진술한다. "아무것도 배선되지 않았다"고 읽은 운영자는 배포 아티팩트가 이 leaf들을 싣고 있고 app.messaging.enabled 하나로 켜진다는 사실을 모른다. 과대 진술보다는 낫지만, 사고 시 조사 범위를 좁히는 방향의 오류다.

나머지 문서 주장은 재측정에서 일치했다.

  • docs/superpowers/plans/…:13 "messaging-core-api에는 Spring/broker/Reactor 의존성을 넣지 않는다" → 참(import 0개, build.gradle 빈 dependencies)
  • docs/messaging/experimental-policy.md:44 "messaging-core-api의 타입을 바꾸지 않는다" → 정책 문장이며 이번 revision에서 위반 근거를 찾지 못함

측정하지 않은 것. docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md의 경로(modules/messaging/…)와 패키지(io.backend.skeleton.messaging.api)는 현재 소스(src/messaging/…, dev.caskeleton.messaging.api)와 다르다. 다만 이것은 계획 문서이고 실행 후 이름이 바뀐 것으로 보이므로 "drift"로 분류하지 않고 §13의 역사로 기록한다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

이 leaf를 건드린 커밋은 4개다.

a24ece9c feat: web, websocket 어댑터 추가 구현
01372634 refactor: 각 어댑터터별 리펙토링 진행
2f5d2fc2 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
d646c2f1 feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가

최초 커밋 메시지는 24개 leaf라고 적었고 현재 registry의 messaging leaf는 25개다. 이후 커밋에서 하나가 늘었다는 뜻이며, 커밋 메시지는 그 시점의 사실이므로 drift로 분류하지 않는다.

코드 주석이 보존한 실패 이력이 이 leaf의 가장 밀도 높은 사료다. 아래는 전부 "예전에는 이랬고 그래서 무엇이 깨졌다"를 현재 코드가 직접 적어 둔 것이다.

위치 이전 상태 그것이 만든 실패
WireSafeText 클래스 javadoc 각 값 객체가 Java char로만 길이 검사 240자 = 최대 960바이트. 바이트를 세는 브로커가 발행 시점에 거절
HeaderName.TOKEN 주석 "not blank, at most 128 bytes" CR/LF/NUL/콜론이 통과 → 헤더 인젝션, 이름 절단, 레코드 분할
HeaderName 공백 검사 주석 strip()으로 trim Authorization 이 denylist에는 같은 이름, wire에는 다른 이름 → 우회
HeaderValue javadoc 길이 상한만 값 안의 CRLF가 line-oriented 바인딩에서 헤더를 끝내고 새 헤더 시작
CorrelationId javadoc 160 문자 상한 640바이트 값이 흐름 중간에 거절됨 — 다른 identity로 재전송할 수 없는 메시지에서
MessageEnvelope 생성자 주석 partitionKey/orderingKey 무제한 orderingKey는 여러 바인딩이 wire에 싣는다 → 헤더와 같은 인젝션 표면
MessageId 생성자 주석 아무 UUID나 허용 v4가 같은 컬럼에 들어가 outbox의 "oldest first"를 무력화
TraceContext javadoc non-null 검사만 파싱 불가 traceparent를 collector가 드롭 → 조사 중인 바로 그 hop에서 trace 소실
ReservedHeaders.PLATFORM_PREFIX 주석 NAMES 정확 일치 다음 릴리스가 msg.x를 정의하는 순간 기존 애플리케이션이 봉투 메타데이터를 덮어씀
ReservedHeaders.TENANT javadoc 헤더 이름 자체가 없었음 소비된 메시지가 전부 빈 tenant로 재구성됨 — 하위 authorization/파티셔닝이 읽는 필드
MessageHeaders.SECRET_SEGMENTS 주석 정확 이름 매칭만 x-api-key·auth-token·db_password가 전부 통과
PublishOptions javadoc 자유형 hint map 존재 읽는 쪽이 없어서 런타임 거절만 유발하는 escape hatch
CanonicalEnvelopeHeaders javadoc 예약 네임스페이스를 통째로 "위조 가능한 내용"으로 취급 msg.retry-attempt까지 드롭 → attempt 카운터가 1로 재시작, retry 예산이 아무것도 제한하지 못함
DefaultDeliveryProcessor javadoc (다른 leaf, 이 계약 관련) HandleResult를 정산에 연결하는 곳이 없었음 각 브로커 adapter가 retry/dead-letter의 뜻을 각자 결정

이 목록 자체가 이 leaf의 성격을 말한다 — 13개 이상의 wire 경계 결함을 한 번에 정리한 흔적이고, 대부분이 "검사가 없었다"가 아니라 "검사가 잘못된 단위(문자 vs 바이트, 정확일치 vs 세그먼트, 이름목록 vs prefix)로 되어 있었다"이다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-269 command evidence/raw/269-messaging-core-api-reachability.txt 21개 타입의 leaf 밖 참조 0(exit=1), DeliveryContext의 test-only 성격, 세 핸들러 계약, new BatchMessageDelivery 0건, leaf의 무의존성 git grep 정적 검색. 리플렉션·서비스로더·저장소 밖 소비자 미포함
EVD-270 command evidence/raw/270-messaging-runtime-membership-doc-drift.txt support-matrix.md:23의 주장과 registry 재측정(25/7/18), CLAUDE.md의 정정 기록, starter 조립 edge 한 시점 registry snapshot
EVD-271 command :messaging:messaging-core-api:test --rerun-tasks BUILD SUCCESSFUL, 79 tests / 0 skipped / 0 failures 순수 단위 테스트 레인. 브로커·Spring 없음

evidence/raw/에는 primary output만 둔다. 위 해석은 전부 이 문서가 소유한다.


15. 명시적 설계 이유와 추론을 구분한 정리

명시적(코드 주석·javadoc·테스트 이름·설계 문서가 직접 말함)

  • EXACTLY_ONCE·GLOBAL 부재 — DeliveryGuarantee/OrderingScope javadoc + CoreValueTypesTest
  • 3상태 발행 결과 — PublishCompletion javadoc
  • 증거가 결론보다 먼저 — PublishEvidence javadoc
  • 정제 대신 거절 — WireSafeText javadoc
  • msg. prefix 소유 — ReservedHeaders.PLATFORM_PREFIX 주석
  • 세그먼트 매칭 + 인접 결합 — MessageHeaders.carriesACredential 주석
  • 예약 네임스페이스 2분할 — CanonicalEnvelopeHeaders javadoc
  • messageId 보존이 withPayload의 목적 — MessageEnvelope.withPayload javadoc
  • rand_a를 카운터로 — UuidV7 javadoc
  • 애플리케이션이 이 플랫폼을 참조하지 않는 이유 — CleanArchitectureTest:229 .because(...)
  • runtime_memberships의 현재 의미 — src/messaging/CLAUDE.md:46-70

추론(이 문서의 판단이며 코드가 직접 말하지 않음)

  • MessageHandler<T>가 미사용인 것은 DefaultDeliveryProcessor가 다른 시그니처를 택했기 때문이다 → 추론. 두 사실(선언 존재, 다른 시그니처 사용)은 관측이고, 인과는 추론이다. 커밋 메시지나 ADR에서 이 선택의 근거를 찾지 못했다.
  • 12개 예외가 미사용인 것은 adapter들이 예외 대신 결과 record 경로를 택했기 때문이다 → 추론. MessagingConfigurationException(59회)처럼 실제 쓰이는 것들이 설정/검증 계열에 몰려 있다는 관측에서 나온 설명이다.
  • 저장소 밖 소비자가 있을 가능성 → 가설. 확인 수단이 이 저장소 안에 없다.

관측했으나 원인을 모름

  • MessagingRedactor.java:24가 상수 대신 "msg.id" 리터럴을 쓰는 이유
  • ContentType만 바이트가 아니라 문자로 상한을 두는 이유

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • production 85파일 전부의 계약·불변식·경계값 (§4, §8)
  • 79개 테스트가 실제로 통과하고 무엇을 단언하는지 (§10)
  • 21개 타입의 leaf 밖 참조 0 — 재현 가능한 명령과 exit code로 (§12.1)
  • 선언된 핸들러 계약과 배선된 핸들러 계약의 불일치 (§12.1)
  • 배치 metadata를 만드는 두 지점과, 그것을 받을 BatchMessageDelivery가 0건이라는 사실 (§12.1)
  • support-matrix.md:23의 주장이 현재 registry와 어긋난다는 것 (§12.4)
  • 이 leaf가 외부 의존성 0이라는 것 (§1, §12.2)
  • 코드 주석이 보존한 13건 이상의 이전 결함 이력 (§13)

확인하지 못한 것

  • 경합 하 UuidV7 단조성. updateAndGet의 CAS 성질에서 추론되지만 다중 스레드 테스트가 없다. CoreValueTypesTest는 단일 스레드 2회 호출만 본다.
  • 저장소 밖 소비자. 이 템플릿을 가져다 쓰는 파생 프로젝트가 MessageHandler·CapabilityRegistry 등을 구현하는지 확인할 방법이 이 저장소 안에 없다. §12.1(c)와 §17의 판단이 이 미지수에 걸려 있다.
  • 실제 브로커가 이 경계값을 받아들이는지. 128바이트 헤더 이름, 32,768바이트 헤더 총량, 1,024바이트 ordering key가 Kafka·RabbitMQ에서 실제로 통과하는지는 이 leaf의 레인이 증명하지 않는다. messaging-kafka/messaging-rabbit의 컨테이너 레인이 소유하고, 그 레인들은 이번 분석에서 실행하지 않았다.
  • MessagingRedactor의 리터럴이 실제로 어긋난 적이 있는지. 현재는 ReservedHeaders.MESSAGE_ID와 값이 같다.

17. 손볼 것

P2 — 선언된 핸들러 계약이 배선된 것과 다르다
  • 사실. MessageHandler<T>(delivery/MessageHandler.java:14)의 저장소 전체 참조가 0이다. 핸들러 결과를 정산으로 바꾸는 유일한 지점 DefaultDeliveryProcessorFunction<MessageEnvelope<EncodedMessage>, HandleResult>를 받는다.
  • 근거. evidence/raw/269 §A, §D.
  • 왜 문제인가. MessageDelivery가 빠지면서 deliveryAttempt·redelivered·handlerDeadline·shutdownRequested가 핸들러에 도달할 수 없다. DeliveryContext의 javadoc이 설명하는 graceful drain 협력은 현재 배선으로는 성립하지 않는다. 그리고 새 소비자를 붙이는 사람은 공개 API에서 MessageHandler를 먼저 보게 되는데, 그것을 구현해도 아무 데도 꽂히지 않는다.
  • 확인 방법. git grep -n -w MessageHandler -- src ':!src/messaging/messaging-core-api' → exit 1. DefaultDeliveryProcessor.java:40,47 확인.
  • 후보. (a) DefaultDeliveryProcessorMessageHandler<T>를 받도록 시그니처를 맞춘다 — MessageDelivery를 조립해야 하므로 DeliveryContext 생성 책임을 runtime에 준다. (b) MessageHandler·DeliveryContext를 이 leaf에서 제거하고 실제 계약만 남긴다. (c) 파생 프로젝트가 구현하는 확장점이라면 그 사실을 javadoc과 support-matrix.md에 명시한다.
  • 다음 단계. 세 선택지는 "저장소 밖 소비자가 있는가"라는 미지수에 걸린다(§16). 그 답을 먼저 정해야 한다 → OPEN QUESTION 후보. 답이 정해지면 CASE 승격 가능.
P2 — 배치 metadata를 만들고 넘길 곳이 없다
  • 사실. KafkaBatchConsumerRegistrar:104RabbitBatchConsumerRegistrar:139BatchDeliveryMetadata를 만들고, 두 javadoc 다 "the batch-wide metadata handed to the handler"라고 적는다. new BatchMessageDelivery는 저장소 전체에서 0건이고 BatchMessageHandler 참조도 0건이다.
  • 근거. evidence/raw/269 §E (git grep 'new BatchMessageDelivery' exit=1).
  • 왜 문제인가. 두 registrar는 브로커별로 다른 정확한 계산을 한다 — Kafka는 파티션 단위 커밋이라 settlableAsBatch=true, Rabbit은 multiple-ack이 in-flight까지 정산하므로 false. 이 판단이 계산되어 어디에도 전달되지 않는다. javadoc은 존재하지 않는 수신자를 가리킨다.
  • 확인 방법. git grep -n 'new BatchMessageDelivery' -- 'src/**/*.java' → exit 1.
  • 후보. 배치 경로를 완성하거나(handler 인터페이스를 registrar에 연결), 미완성임을 javadoc과 support-matrix.md에 표시하거나, BatchMessageHandler/BatchMessageDelivery를 제거한다.
  • 다음 단계. CASE 후보. 재현이 정적 검색으로 끝나고 결론이 경계 안에서 닫힌다.
P2 — 운영자용 지원 매트릭스가 런타임 편입을 반대로 적는다
  • 사실. docs/messaging/support-matrix.md:23이 "registry의 messaging leaf는 모두 runtime_memberships가 비어 있다 … 어느 composition root에도 편입되지 않았다"고 적는다. 현재 registry는 25개 중 18개["app-bootstrap"]이고 messaging-core-api가 그 안에 있다.
  • 근거. evidence/raw/270.
  • 왜 문제인가. 같은 문단이 권위로 지목하는 src/messaging/CLAUDE.md:46-59는 이미 정정을 기록했고 "정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다"는 결론까지 적었다. 그 결론이 support-matrix에는 적용되지 않았다. 배포 아티팩트가 실제로 이 leaf들을 싣고 app.messaging.enabled 하나로 켜진다는 사실을 운영자가 문서에서 알 수 없다.
  • 확인 방법. evidence/raw/270의 python 블록 재실행.
  • 후보. support-matrix의 해당 문장을 삭제하고 CLAUDE.md로 위임하거나(문장이 이미 그렇게 하고 있다), registry에서 파생하는 생성 문서로 바꾼다.
  • 다음 단계. CASE 후보 + REFERENCE 후보("숫자는 세지 말고 소유자에게 위임하거나 게이트로 붙든다"). 두 문서가 같은 revision에서 모순되고 틀린 쪽이 옳은 쪽을 가리킨다는 형태 자체가 재사용 가능한 기준이다.
P3 — 12개 예외가 선언만 되어 있다
  • 사실. 23개 구체 예외 중 12개가 leaf 밖 참조 0이다(§6.2 표).
  • 근거. evidence/raw/269 §B.
  • 왜 문제인가. 지금 당장 깨지는 것은 없다. 다만 MessagePublishAmbiguousException처럼 설계의 중심 개념에 이름을 준 타입이 던져지지 않으면, 그 개념이 실제로 어떤 경로로 표현되는지(결과 record)를 읽는 사람이 스스로 알아내야 한다. 그리고 src/messaging/CLAUDE.md:44 — "새 public 타입은 그 모듈의 계약이다. 삭제·시그니처 변경은 breaking change로 취급한다" — 때문에 나중에 정리하는 비용이 계속 커진다.
  • 확인 방법. evidence/raw/269 §B 재실행.
  • 후보. adapter들이 결과 record 대신 예외를 던져야 하는 지점을 정하거나, 미사용 예외를 제거하거나, "이것은 파생 프로젝트용 어휘"임을 명시한다.
  • 다음 단계. P2 첫 항목과 같은 미지수(저장소 밖 소비자)를 공유한다 → 그 OPEN QUESTION에 MERGED 후보.
P3 — MessagingRedactor가 상수 대신 문자열 리터럴을 쓴다
  • 사실. messaging-observability/.../MessagingRedactor.java:24"msg.id"를 리터럴로 갖는다. ReservedHeaders.MESSAGE_ID 상수가 있다.
  • 근거. git grep '"msg\.' — production 매치는 이 한 곳뿐.
  • 왜 문제인가. 상수가 바뀌면 redaction이 조용히 대상을 잃는다. 컴파일러가 잡지 않는다.
  • 확인 방법. git grep -n '"msg\.' -- 'src/**/*.java' | grep -v messaging-core-api
  • 후보. 리터럴을 ReservedHeaders.MESSAGE_ID로 교체.
  • 다음 단계. messaging-observability leaf SSOT가 소유한다. 여기서는 교차 참조만 남긴다.
P3 — WireSafeText의 규칙이 leaf 경계에서 멈춘다
  • 사실. WireSafeText의 leaf 밖 참조 0. 제어문자·인코딩 경계를 각자 검사하는 곳이 저장소에 최소 15개.
  • 근거. §12.3(b).
  • 왜 문제인가. javadoc이 "Each copy of this check ... was one more place for the rule to drift"라고 적었고 그 통합을 leaf 안에서만 했다. 저장소 수준에서는 같은 drift가 그대로 남아 있다.
  • 확인 방법. git grep -l -E 'requireNoControls|control character|0x7F' -- 'src/**/*.java'
  • 후보. 규칙을 공유 위치(shared-contract)로 올리거나, leaf 경계를 이유로 중복을 명시적으로 수용한다고 적는다.
  • 다음 단계. 저장소 전역 판단이므로 cross-scope 소유. 여기서는 관측만 기록한다.
확인된 설계(문제 아님)
  • 외부 의존성 0 — 계획 문서의 제약이 현재 소스에서 성립
  • EXACTLY_ONCE/GLOBAL 부재가 테스트로 고정됨
  • PublishResult의 14개 금지 조합 중 9개가 테스트로 커버됨
  • 자격증명 세그먼트 매칭의 양방향(거절/오탐 회피) 테스트 존재
  • msg. prefix 소유가 테스트로 고정됨
  • W3C traceparent 무효 7종이 파라미터 테스트로 커버됨

Source anchors

id kind path / command revision what it proves limitations
MCA-001 registry src/config/architecture/modules.json 21234e38 leaf id, allowed_dependencies: [], runtime_memberships: ["app-bootstrap"] 선언이며 런타임 실행 자체는 아님
MCA-002 build src/messaging/messaging-core-api/build.gradle same 선언 의존성 0 convention plugin의 test 의존성은 별개
MCA-003 code src/main/java/**/api/*.java (12) same 봉투와 값 객체 불변식, 바이트 경계, UUIDv7 검증
MCA-004 code src/main/java/**/api/header/*.java (5) same 헤더 문법, 예약 prefix 소유, 자격증명 세그먼트 매칭, 두 factory 분리 실제 브로커 수용 여부는 미포함
MCA-005 code src/main/java/**/api/publish/*.java (17) same 3상태 완료, 14개 금지 조합, 증거 우선 순서 adapter가 이를 지키는지는 별개
MCA-006 code src/main/java/**/api/delivery/*.java (13) same HandleResult sealed 4변형, attempt 1 규칙, 선언된 핸들러 계약 배선 여부는 §12가 답함
MCA-007 code src/main/java/**/api/settlement/*.java (5) same 정산 3상태와 불변식
MCA-008 code src/main/java/**/api/error/*.java (26) same 10개 카테고리, 기본 retryable 정책, 23개 예외의 카테고리 전수
MCA-009 code src/main/java/**/api/destination/*.java (7) same 논리 목적지, 12개 capability boolean capability 선언이 실제 브로커와 맞는지는 별개
MCA-010 test src/test/java/** (8 클래스 / 79 테스트) same §10 표의 단언 순수 단위. 브로커·Spring 없음
MCA-011 architecture test src/app-bootstrap/.../CleanArchitectureTest.java:229-240 same ..application..dev.caskeleton.messaging.. 금지와 그 이유 정적 분석. 헬퍼/AOP 우회는 별도
MCA-012 assembly src/app-bootstrap/build.gradle:87 same starter를 통한 전이 편입 경로 실행 활성화는 app.messaging.enabled가 결정
MCA-013 module policy src/messaging/CLAUDE.md:44, 46-70 same public 타입 = 계약, runtime membership의 현재 의미와 정정 기록 정책 문서
MCA-014 doc docs/messaging/support-matrix.md:18-27 same 운영자용 등급표와 런타임 편입 주장 23행이 registry와 어긋남(§12.4)
MCA-015 design doc docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:7,13 same 무의존성 제약의 원래 근거 계획 문서. 경로/패키지는 이후 변경됨
MCA-016 cross-leaf code messaging-runtime-core/.../DefaultDeliveryProcessor.java:22-99 same 핸들러 결과 → 정산의 유일한 지점과 그 시그니처 해당 leaf SSOT가 소유
MCA-017 cross-leaf code messaging-kafka/.../KafkaBatchConsumerRegistrar.java:102-124, messaging-rabbit/.../RabbitBatchConsumerRegistrar.java:133-160 same 배치 metadata 생성 지점과 "handed to the handler" javadoc 해당 leaf SSOT가 소유
MCA-018 cross-leaf code messaging-reliability-api/.../IdempotentMessageHandler.java:21-32 same 세 번째 핸들러 계약의 존재 해당 leaf SSOT가 소유
EVD-269 command evidence/raw/269-messaging-core-api-reachability.txt same §12.1·§12.2 전부, exit code 포함 정적 git grep. 리플렉션/서비스로더/저장소 밖 미포함
EVD-270 command evidence/raw/270-messaging-runtime-membership-doc-drift.txt same §12.4의 drift, registry 재측정 25/7/18 한 시점 snapshot
EVD-271 command ./gradlew :messaging:messaging-core-api:test --rerun-tasks same BUILD SUCCESSFUL, 79 / 0 skipped / 0 failures 순수 단위 레인

A19-MESSAGING-INBOX-JDBC-POSTGRESQL. messaging-inbox-jdbc-postgresql

분석 중에는 messaging/MESSAGING-INBOX-JDBC-POSTGRESQL.md 파일이었다. 734줄.

messaging-inbox-jdbc-postgresql 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-inbox-jdbc-postgresql SSOT owner: messaging-inbox-jdbc-postgresql integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-inbox-jdbc-postgresql
  • canonical state analysisFile: §A19-MESSAGING-INBOX-JDBC-POSTGRESQL
  • source path: src/messaging/messaging-inbox-jdbc-postgresql
  • registry allowed_dependencies: ["messaging-core-api", "messaging-reliability-api"]
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 6
production LOC 542
패키지 1 (dev.caskeleton.messaging.inbox)
migration 1 (V2__messaging_inbox.sql)
test 파일 4
test 메서드(실행 확인) 25
외부 의존성 spring-jdbc, spring-tx(implementation) · testcontainers·postgresql·messaging-testkit(test)

여섯 타입:

타입 역할 leaf 밖 참조
JdbcInboxRepository InboxRepository 구현 0
IdempotentConsumer 예약+부작용을 한 트랜잭션에 1
TransactionalInboxHandler IdempotentMessageHandler 구현 1
InboxCleanupJob 보존 스윕 1
InboxRetentionPolicy 보존 규칙 1
InboxOutcome 처리/중복 결과 0
Coverage ledger
scope/file group count disposition reason
src/main/java/** (6) 6 FULL_READ 전 파일 본문 확인
src/main/resources/db/migration/messaging/V2__messaging_inbox.sql 1 FULL_READ 18줄 전문
src/test/java/** (4) 4 FULL_READ fake 구현·테스트명·단언 확인
build.gradle 1 FULL_READ 주석 포함 17줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

messaging-reliability-apiInboxRepository·IdempotentMessageHandler 포트를 PostgreSQL로 구현한다. 이름이 기술을 드러낸다 — docs/messaging/support-matrix.md가 그 개명 이유를 적는다(MSG-023).

메커니즘 전체가 하나의 SQL 문장에 있다.

INSERT INTO messaging_inbox (message_id, consumer_id, processed_at)
VALUES (?, ?, ?)
ON CONFLICT (message_id, consumer_id) DO NOTHING
// JdbcInboxRepository.java:20-23
 * <p>Reservation is an {@code INSERT ... ON CONFLICT DO NOTHING} whose affected-row count is the
 * answer: one means first delivery, zero means already processed. The composite primary key does
 * the work, so there is no read-then-write race  two concurrent deliveries of the same message
 * cannot both see "not processed" and both proceed.

migration이 같은 사실을 반대편에서 적는다.

-- The composite primary key is the deduplication mechanism: reserving a message is an INSERT that
-- either succeeds or violates the key, inside the same transaction as the handler's side effect.
-- Two independent consumers of the same event each get their own row, so one cannot suppress the
-- other.

build.gradle 주석이 테스트 전략을 명시한다.

// Live-database certification. The reliability patterns are claims about transaction
// boundaries and uniqueness constraints, and only a real database can settle them.
testImplementation 'org.testcontainers:testcontainers-postgresql'

그리고 실제로 실행된다InboxPostgresIT 6개가 기본 test 태스크에서 통과한다(§10).


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), messaging-reliability-api(api), spring-jdbc·spring-tx(implementation).

나가는 것: messaging-spring-boot-starter.

배선됨. starter의 MessagingReliabilityAutoConfiguration이 셋을 만든다.

bean 이 leaf의 타입
InboxRetentionPolicy o
InboxCleanupJob o
TransactionalInboxHandler<Object> o (IdempotentConsumer를 받음)

JdbcInboxRepository는 그 목록에 없다 — InboxRepository bean을 누가 만드는지는 starter leaf가 답한다.

Spring 타입을 두 곳에서 쓴다 — DataSourceUtilsTransactionSynchronizationManager. 둘 다 implementation scope이고 public 시그니처에 나오지 않으므로 vendor api 규칙에 맞는다.


3. 패키지/컴포넌트 지도

TransactionalInboxHandler<T>  (IdempotentMessageHandler<T> 구현)
  └── handleOnce(consumerName, delivery, action)
        └── IdempotentConsumer.runOnce(messageId, consumerId, now, sideEffect)
              └── TransactionRunner.inTransaction(...)          ← 호출자가 제공
                    ├── InboxRepository.reserve(...) == false → InboxOutcome.duplicate()
                    └── true → sideEffect.get() → InboxOutcome.processed(...)

JdbcInboxRepository  (InboxRepository 구현)
  ├── reserve(MessageId, String, Instant)   ← requireActiveTransaction 3검사 후 위임
  ├── reserve(Connection, ...)              ← package-private, 실제 INSERT
  ├── isProcessed(...)                      ← 자기 커넥션
  ├── purgeProcessedBefore(Instant, int)    ← LIMIT + FOR UPDATE SKIP LOCKED. 호출자 0 (§12.1)
  └── purgeProcessedBefore(Instant)         ← 무제한 DELETE. 이것이 불린다

InboxCleanupJob(inbox, policy, maxBatches)
  ├── 생성자가 policy.validate()
  └── runOnce(now) → maxBatches회 루프, 매회 무제한 purge

InboxRetentionPolicy(retention, maximumRedeliveryWindow)
  ├── REQUIRED_SAFETY_FACTOR = 2.0
  └── validate() → retention >= window * 2 아니면 INBOX_RETENTION_TOO_SHORT

4. 계약·불변식·상태 모델

4.1 requireActiveTransaction — 세 겹 검사

이 leaf에서 가장 중요한 안전 장치이고 이전 결함이 javadoc에 있다.

// JdbcInboxRepository.java:53-57
 * <p>Package-private. It used to be public and was the only path that actually joined the
 * caller's transaction, while the interface method  the one {@code IdempotentConsumer} calls 
 * opened a raw connection that auto-commits. A reservation that commits on its own while the
 * business side effect rolls back is a message that will never be redelivered and whose work
 * never happened.

두 개의 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다. 현재는 interface 메서드가 세 가지를 확인한다.

검사 실패 시 메시지의 핵심
isActualTransactionActive() "a reservation that commits alone marks a message processed whose work may still roll back"
!isCurrentTransactionReadOnly() "the current one is read-only"
hasResource(dataSource) "it is bound to another, so the reservation and the side effect would commit independently"

세 번째가 특히 정교하다 — 트랜잭션이 활성이어도 다른 DataSource에 묶여 있으면 거절한다. 멀티 데이터소스 배포에서 실제로 발생하는 형태이고, 그 경우 예약과 부작용이 서로 다른 트랜잭션에 들어간다.

세 검사 전부 같은 코드 INBOX_TRANSACTION_REQUIRED를 쓴다 — 메시지만 다르다.

// requireActiveTransaction javadoc:96-99
 * <p>The reservation and the side effect it guards have to commit or roll back together. Running
 * the reservation on its own connection breaks that on the rollback path only  which is the path
 * nobody exercises before production, and the one where the message is lost for good.

**"the path nobody exercises before production"**가 이 leaf의 테스트 전략을 설명한다 — InboxPostgresIT.aRolledBackTransactionLeavesNoReservationAndNoSideEffect가 정확히 그 경로를 실 DB에서 돈다.

4.2 IdempotentConsumer — 트랜잭션을 열지 않는다
// :12-15
 * <p>The reservation and the side effect must share one transaction. This class does not open that
 * transaction itself  the caller supplies a runner that does  because the boundary belongs to the
 * application's data access layer, and a nested or separate transaction here would silently break
 * the guarantee while still looking correct.

TransactionRunner가 함수형 인터페이스이고 <T> T inTransaction(Supplier<T> work) 하나다. 즉 이 leaf는 Spring @Transactional에 의존하지 않고 경계 제공을 호출자에게 위임한다. JdbcInboxRepository.requireActiveTransaction이 그 위임이 지켜졌는지를 런타임에 확인한다 — 위임과 검증이 짝을 이룬다.

중복이 정상 결과라는 것도 명시돼 있다 — "A duplicate is not an error. It is the expected consequence of at-least-once delivery, so the skip path is a normal outcome rather than an exception."

4.3 TransactionalInboxHandler — 세 가지를 할 수 없다
// :20-23
 * <p>Reservation and effect commit together, in the runner's single transaction. Everything else
 * about this class follows from that: it cannot settle the message (settlement is not
 * transactional), it cannot publish (the publish would survive a rollback), and it cannot catch and
 * swallow the action's exception (the rollback is how the reservation is undone).

세 금지가 messaging-reliability-apiTransactionalMessageAction javadoc이 구현자에게 요구한 것과 대칭이다 — 그쪽은 action에게, 이쪽은 handler에게.

예외 처리가 그 세 번째를 지킨다.

try {
  action.apply(delivery);
} catch (Exception failure) {
  // Wrapped, not swallowed: the transaction runner has to see a throw to roll the
  // reservation back along with the effect.
  throw new ActionFailedException(failure);
}

ActionFailedException이 private RuntimeException이고, 바깥에서 잡아 HandleResult.Retry로 번역한다. checked exception을 트랜잭션 runner를 통과시키기 위한 캐리어다.

중복은 성공으로 보고한다.

private static HandleResult duplicateIsSuccess() {
  // The effect already ran in an earlier delivery. Settling is correct; redelivering is not.
  return HandleResult.success();
}

실패는 TRANSIENT_INFRASTRUCTURE + retryable = true + exceptionType에 원인 클래스 단순명 — FailureDescriptorOptional<String> exceptionType을 실제로 채우는 저장소 내 드문 지점이다.

4.4 InboxRetentionPolicy — 곱셈 안전계수
// :11-18
 * <p>Retention must exceed the broker's maximum redelivery window. That is not a tuning preference:
 * a row pruned while the broker can still redeliver its message turns the inbox into a no-op for
 * exactly that message, and the side effect runs a second time. The failure is silent, rare, and
 * only happens under the conditions that already made the day bad.
 *
 * <p>The safety margin is multiplicative rather than additive so that it scales with the window
 * itself. A stream whose redelivery window is measured in days needs more slack than one measured
 * in minutes, for the same reason: the estimate of that window is proportionally less certain.

REQUIRED_SAFETY_FACTOR = 2.0, DEFAULT_RETENTION = 7일.

messaging-reliability-apiInboxRepository.purgeProcessedBefore javadoc이 요구하고 강제하지 않은 규칙을 이 leaf가 강제한다. 그 leaf §17이 "미강제"로 기록한 것이 여기서 validate()가 된다 — 다만 validate()InboxCleanupJob 생성자만 부른다. 즉 cleanup job을 만들지 않는 배포에서는 여전히 검사되지 않는다.

required()Math.round(window.toMillis() * 2.0)이다. 곱셈 이유가 적혀 있고, theRequiredRetentionScalesWithTheWindow 테스트가 2일 창 → 4일 요구를 확인한다.

4.5 InboxCleanupJob — 선언과 구현이 어긋난다

javadoc이 두 가지를 약속한다.

// :10-16
 * <p>Deletes in bounded batches. A single unbounded {@code DELETE} over a table that has been
 * accumulating for weeks holds locks long enough to block the very reservations the inbox exists to
 * serve, so the cleanup would cause the outage it is meant to prevent.
 *
 * <p>The policy is validated before the first deletion. Running a cleanup under a retention that is
 * shorter than the redelivery window would actively create the duplicate-processing bug, so the job
 * refuses to start rather than dutifully deleting the rows.

두 번째는 지켜진다 — 생성자가 policy.validate()를 부르고 테스트가 확인한다.

첫 번째는 지켜지지 않는다.

public static final int DEFAULT_BATCH_SIZE = 1_000;   // ← 선언되고 어디서도 쓰이지 않음
...
for (int batch = 0; batch < maxBatches; batch++) {
  int deleted = inbox.purgeProcessedBefore(cutoff);    // ← 무제한 overload
  ...
}

InboxRepository에는 두 오버로드가 있다.

오버로드 구현
purgeProcessedBefore(Instant, int) WITH expired AS (SELECT … LIMIT ? FOR UPDATE SKIP LOCKED) DELETE …
purgeProcessedBefore(Instant) DELETE FROM messaging_inbox WHERE processed_at < ?

job은 후자를 부른다. 첫 호출이 컷오프 이전 전부를 한 문장으로 지우고, 두 번째 호출이 0을 반환해 루프가 끊긴다. maxBatches는 사실상 의미가 없고 DEFAULT_BATCH_SIZE는 죽은 상수다.

javadoc이 "cleanup would cause the outage it is meant to prevent"라고 서술한 바로 그 동작을 한다. §12.1·§17.

4.6 InboxOutcome — 두 상태

(boolean processed, Optional<T> result). processed(value)duplicate() 두 factory.

TransactionalInboxHandlerT = InboxResult로 쓰고 항상 InboxResult.APPLIED를 넣는다 — §12.3.

4.7 migration
CREATE TABLE messaging_inbox
(
    message_id   UUID         NOT NULL,
    consumer_id  VARCHAR(160) NOT NULL,
    processed_at TIMESTAMPTZ  NOT NULL,
    CONSTRAINT pk_messaging_inbox PRIMARY KEY (message_id, consumer_id)
);
CREATE INDEX ix_messaging_inbox_processed_at ON messaging_inbox (processed_at);

message_idUUID 타입이다 — MessageId가 UUIDv7만 허용하므로(messaging-core-api §4.9) 컬럼 타입이 그 제약과 맞는다.

consumer_id VARCHAR(160)IdempotentConsumer가 공백만 거절하고 길이를 보지 않는다. 160자를 넘는 consumerId는 DB가 거절한다. 애플리케이션 층에 대응 검증이 없다. §17.

인덱스 주석이 보존 규칙을 다시 적는다.


5. 주요 실행 경로

수신 처리: handleOnce(name, delivery, action)consumer.runOnce(messageId, name, now, () -> { action.apply(delivery); return APPLIED; }) → runner가 트랜잭션 열기 → repository.reserve(...) → 세 검사 → INSERT … ON CONFLICT DO NOTHING → 1행이면 부작용 실행, 0행이면 duplicate() → 커밋 → HandleResult.success()

실패: action 예외 → ActionFailedException → runner가 롤백(예약도 함께) → HandleResult.Retry("INBOX_ACTION_FAILED")

보존: cleanupJob.runOnce(now)policy.cutoff(now) → 무제한 DELETE 1회 → 두 번째 호출 0 → 종료


6. 실패 경로와 복구/번역

코드 예외 조건
INBOX_TRANSACTION_REQUIRED MessagingConfigurationException 트랜잭션 없음/읽기전용/다른 DataSource
INBOX_RESERVE_FAILED MessagingConfigurationException 예약 SQL 실패
INBOX_QUERY_FAILED MessagingConfigurationException 조회 SQL 실패
INBOX_PURGE_FAILED MessagingConfigurationException 스윕 SQL 실패
INBOX_RETENTION_TOO_SHORT MessagingConfigurationException 보존 < 창 × 2
INBOX_ACTION_FAILED HandleResult.Retry(예외 아님) action 실패

SQL 실패 셋이 전부 MessagingConfigurationException이다. 그 예외의 카테고리는 CONFIGURATION이고 retryable = false다. 그런데 SQLException의 원인은 대부분 일시적 인프라 문제(연결 끊김, 데드락, 타임아웃)다. 즉 재시도 가능한 실패가 재시도 불가로 분류된다. §17.

INBOX_ACTION_FAILEDTRANSIENT_INFRASTRUCTURE/retryable = true이고 예외가 아니라 HandleResult로 흐른다 — 분류가 정확하다.


7. 트랜잭션·동시성·수명주기

이 leaf의 주제 자체가 트랜잭션이다.

지점 메커니즘
중복 제거 복합 PK + ON CONFLICT DO NOTHING의 영향 행 수
예약·부작용 원자성 호출자의 TransactionRunner + requireActiveTransaction 3검사
커넥션 참여 DataSourceUtils.getConnection/releaseConnection — Spring 트랜잭션 동기화 커넥션을 얻는다
스윕 격리 bounded overload가 FOR UPDATE SKIP LOCKED호출되지 않음

DataSourceUtils.getConnection은 활성 트랜잭션에 묶인 커넥션이 있으면 그것을 주고, 없으면 새로 연다. 그래서 requireActiveTransaction먼저 도는 것이 필수다 — 없으면 새 커넥션이 열리고 자동 커밋된다. 그것이 §4.1의 이전 결함이다.

isProcessed와 두 purge*dataSource.getConnection()을 직접 쓴다 — 트랜잭션에 참여하지 않는다. javadoc이 그것을 명시한다("The no-argument overload is provided only for retention sweeps and read-only queries").

동시성 원시 요소는 DB에 있다. Java 쪽에 락이나 원자 변수가 없다.

수명주기 참여 없음 — InboxCleanupJob을 스케줄링하는 것은 starter다.


8. 설정·기능 플래그·환경 차이

상수 사용
InboxCleanupJob.DEFAULT_BATCH_SIZE 1,000 없음
InboxRetentionPolicy.REQUIRED_SAFETY_FACTOR 2.0 required()
InboxRetentionPolicy.DEFAULT_RETENTION 7일 starter가 참조할 수 있음
consumer_id 컬럼 폭 160자 migration

설정 파일 없음. maxBatches와 두 Duration이 생성자 인자다.


9. 퍼시스턴스/외부 시스템 세부

PostgreSQL 전용이다. 세 SQL이 벤더 기능을 쓴다.

구문 용도
ON CONFLICT (…) DO NOTHING 예약. PostgreSQL 고유
FOR UPDATE SKIP LOCKED bounded 스윕. PostgreSQL 9.5+
WITH … DELETE … USING bounded 스윕. CTE + USING
TIMESTAMPTZ 컬럼 타입

leaf 이름이 그 사실을 드러낸다.

statement.setObject(1, messageId.value())java.util.UUID를 그대로 넘긴다 — PostgreSQL JDBC 드라이버가 UUIDuuid 매핑을 지원한다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-inbox-jdbc-postgresql:test. BUILD SUCCESSFUL, 25 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
InboxPostgresIT 6 실 PostgreSQL에서: 첫 예약 성공/둘째 실패, 두 소비자 각각 1회, 조회 가시성, 재전달이 부작용을 두 번 실행하지 않음, 롤백이 예약도 부작용도 남기지 않음, 보존 삭제 bounded 스윕(무제한 overload를 부른다)
JdbcInboxTransactionRequirementTest 4 트랜잭션 없음/읽기전용/다른 DataSource 거절이 커넥션 요청 전에 일어남, 코드가 검색 가능
IdempotentConsumerTest 6 첫 실행/재전달 스킵/두 소비자/한 트랜잭션 공유/조회 가시성/보존 삭제 in-memory fake
InboxOperationsTest 9 보존 규칙 4개, cleanup 루프 2개, 소비자별 1회, 재전달 억제, InboxResult 세 값의 isSafeToSettle bounded 배치(§10.2)
10.1 컨테이너 레인이 실제로 돈다

InboxPostgresIT@Testcontainers이고 기본 test 태스크에서 6개가 통과했다. 이 저장소의 다른 컨테이너 레인 중 일부는 별도 태스크에 격리돼 있는데 이것은 아니다.

aRolledBackTransactionLeavesNoReservationAndNoSideEffect가 §4.1이 말한 "the path nobody exercises before production"을 실 DB에서 검증한다. build.gradle 주석의 주장("only a real database can settle them")이 실현된 지점이다.

10.2 cleanupDeletesInBoundedBatches가 증명하지 않는 것

테스트 이름이 속성을 주장한다. 실제 단언은 이렇다.

@Test
void cleanupDeletesInBoundedBatches() {
  InMemoryInbox inbox = new InMemoryInbox(List.of(1000, 500));
  int removed = new InboxCleanupJob(inbox, policy(7일, 1일), 10).runOnce(NOW);
  assertThat(removed).isEqualTo(1500);
  assertThat(inbox.cutoffs).hasSize(3);
}

InMemoryInbox대본을 읽는 fake다.

@Override
public int purgeProcessedBefore(Instant processedBefore) {
  cutoffs.add(processedBefore);
  return pass < deletions.size() ? deletions.get(pass++) : 0;
}

@Override
public int purgeProcessedBefore(Instant processedBefore, int limit) {
  return Math.min(purgeProcessedBefore(processedBefore), limit);
}

무제한 메서드가 미리 준 목록(1000, 500)을 순서대로 반환하고 이후 0을 준다. 아무것도 삭제하지 않고 아무것도 제한하지 않는다.

그래서 이 테스트가 통과로 증명하는 것은 "job이 0을 받을 때까지 루프를 돈다"이고, "삭제가 배치로 제한된다"는 아니다. 1000과 500은 배치처럼 보이는 숫자일 뿐이다.

bounded overload(purgeProcessedBefore(Instant, int))는 fake에도 구현돼 있지만 job이 부르지 않으므로 실행되지 않는다.

cleanupHonoursTheBatchCeilingSoItCannotRunForever는 다른 성질(루프 상한)을 정확히 검증한다 — maxBatches=2에 6개 대본을 주고 호출이 2회임을 확인한다.

10.3 anAlreadyAppliedMessageIsSafeToSettleButAClaimedOneIsNot
assertThat(InboxResult.APPLIED.isSafeToSettle()).isTrue();
assertThat(InboxResult.ALREADY_APPLIED.isSafeToSettle()).isTrue();
assertThat(InboxResult.CLAIMED_ELSEWHERE.isSafeToSettle()).isFalse();

enum 상수의 boolean 필드를 단언한다. 동작이 아니라 선언이다 — messaging-transport-spiMessagingLifecycleTest가 enum 선언 순서를 단언하는 것(그쪽 §10.2)과 같은 형태다. 그리고 §12.3이 보이듯 CLAIMED_ELSEWHERE는 production에서 생성되지 않는다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api","messaging-reliability-api"]
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 Spring 타입이 public 시그니처에 없음 → implementation. 통과
SecretLeakStaticScanTest(observability leaf) 이 leaf 소스도 스캔 대상
Flyway migration V2__messaging_inbox.sql — 네이밍이 messaging 네임스페이스
ArchUnit 전용 규칙 없음

12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/294-bounded-purge-never-called.txt.

12.1 Public surface reachability
타입 leaf 밖 판정
IdempotentConsumer 1 starter
InboxCleanupJob 1 starter
InboxRetentionPolicy 1 starter
TransactionalInboxHandler 1 starter
JdbcInboxRepository 0
InboxOutcome 0 내부 반환 타입

JdbcInboxRepository의 0이 주목된다 — starter가 InboxRepository bean을 만들지 않는다(§2). InboxCleanupJob·TransactionalInboxHandler bean이 InboxRepository/IdempotentConsumer를 인자로 받으므로 누군가 그 bean을 공급해야 하고, 이 leaf의 구현이 그 후보인데 연결이 없다. 그 판정은 starter leaf가 소유한다.

메서드 수준 도달성: bounded 스윕이 호출되지 않는다

InboxRepositoryOutboxRepository 둘 다 purge*Before(Instant, int) 오버로드를 선언하고, 두 JDBC 구현이 실제로 LIMIT를 쓰는 SQL로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 전부 선언·구현·테스트 fake override이고 호출 지점이 하나도 없다.

2 port declarations + 2 production implementations + 5 test fake overrides = 9
None of them is a call site.

두 cleanup job이 무제한 오버로드를 부른다.

// InboxCleanupJob.java:56
int deleted = inbox.purgeProcessedBefore(cutoff);
// OutboxCleanupJob.java:50
int deleted = outbox.purgePublishedBefore(cutoff);

OutboxRepository의 bounded 오버로드 javadoc이 그 상황을 정확히 예고한다.

The unbounded version deletes everything before the cutoff in one statement. On a table that has been accumulating published rows since the last sweep that is a single long transaction holding locks and generating WAL in proportion to the backlog, which shows up as the relay and the business writes stalling behind retention. The cleanup jobs describe themselves as bounded by batch size; this is the parameter that makes that true.

그 파라미터를 부르는 코드가 없다. 두 cleanup job은 여전히 "bounded by batch size"라고 자기를 서술한다.

InboxCleanupJob.DEFAULT_BATCH_SIZE = 1_000은 저장소 전체에서 자기 선언 한 줄만 등장한다.

12.2 Conditional sibling comparison

Spring 주석 0개. starter의 세 bean이 이 leaf 타입을 만든다.

형제 비교가 결정적이다. messaging-outbox-jdbc-postgresql이 같은 구조를 갖는다.

inbox outbox
bounded purge 구현 o (LIMIT + SKIP LOCKED) o
cleanup job이 부르는 것 무제한 무제한
batch size 상수 DEFAULT_BATCH_SIZE(미사용) (outbox leaf가 답함)

두 leaf가 같은 결함을 갖는다. 우연이 아니라 같은 리팩터가 두 곳에 같은 형태로 적용되고 호출부 갱신이 빠진 것으로 보인다 — 추론이며 커밋 근거는 없다.

12.3 Duplicate mechanism sweep

(a) InboxResult의 세 값 중 하나만 생성된다

TransactionalInboxHandler:70InboxResult.APPLIED를 반환하는 것이 production의 유일한 생성 지점이다. ALREADY_APPLIED·CLAIMED_ELSEWHEREInboxOperationsTest의 단언에만 등장한다.

구조적 이유가 있다. InboxRepository.reserveboolean을 반환하므로 세 갈래를 표현할 수 없다. messaging-reliability-apiInboxResult javadoc이 세 값이 필요한 이유를 이렇게 적는다.

Three outcomes, not two. Collapsing ALREADY_APPLIED and CLAIMED_ELSEWHERE into a single "duplicate" would settle a message whose effect is still only half-written by another instance: if that instance then rolls back, the effect is lost and the broker will never redeliver, because this instance already acknowledged it.

포트의 반환 타입이 그 구분을 표현 불가능하게 만든다. reserve가 false를 주면 IdempotentConsumerduplicate()를 만들고 TransactionalInboxHandlerHandleResult.success()를 반환한다 — 즉 정산한다. javadoc이 정산하면 안 된다고 한 경우와 해도 되는 경우가 같은 false로 들어온다.

이 leaf에서 그 구분이 실제로 필요한지는 PostgreSQL의 ON CONFLICT DO NOTHING 동시성 동작에 달려 있고, 그것을 확인하지 않았다. 미커밋 충돌 행이 있을 때 DO NOTHING이 대기하는지 즉시 0을 반환하는지에 따라 CLAIMED_ELSEWHERE 상황이 발생 가능한지가 갈린다. §16·§17.

(b) 보존 규칙이 세 곳에 있다

위치 형태 강제
InboxRepository.purgeProcessedBefore javadoc "Retention must outlive the broker's maximum redelivery window" 없음
이 leaf InboxRetentionPolicy.validate() retention >= window × 2.0 강제(단 InboxCleanupJob 생성 시에만)
messaging-claim-check ClaimCheckPolicy 생성자 retention >= brokerRetention + maxRedeliveryWindow 강제(항상)

세 곳이 같은 종류의 시간 관계를 다루고 강제 시점과 공식이 다르다 — 곱셈(×2.0) vs 덧셈(brokerRetention + window). 두 leaf가 서로를 참조하지 않는다.

(c) 커넥션 획득 방식이 둘

메서드 방식 트랜잭션 참여
reserve(...) DataSourceUtils.getConnection o
isProcessed, purge* dataSource.getConnection() x

의도된 구분이고 javadoc이 명시한다. 중복 아님.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
InboxCleanupJob javadoc: "Deletes in bounded batches" 무제한 오버로드 호출, DEFAULT_BATCH_SIZE 미사용 불일치
같은 javadoc: 정책을 첫 삭제 전에 검증 생성자가 policy.validate() 일치
JdbcInboxRepository javadoc: 예약이 ON CONFLICT DO NOTHING의 영향 행 수 SQL 확인 일치
같은 javadoc: 무인자 오버로드는 "only for retention sweeps and read-only queries" 그 스윕이 무인자를 부르므로 문장은 맞다. 다만 그 스윕이 bounded여야 한다는 다른 javadoc과 충돌 부분 불일치
OutboxRepository javadoc: "this is the parameter that makes that true" 그 파라미터 호출자 0 불일치
migration 주석: 보존 창이 재전달 지연보다 길어야 함 InboxRetentionPolicy가 강제 일치
build.gradle 주석: 실 DB 인증 InboxPostgresIT 6개 통과 일치
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift)

13. Git/설계 문서에서 확인한 변화와 실패 기록

위치 이전 상태 그것이 만든 실패
JdbcInboxRepository.reserve(Connection,…) javadoc 그 메서드가 public이고, interface 메서드는 raw 커넥션을 열어 자동 커밋 부작용이 롤백돼도 예약은 커밋됨 → 메시지는 처리됨으로 남고 작업은 일어나지 않았으며 재전달이 거부됨
JdbcInboxTransactionRequirementTest javadoc 같은 결함을 테스트 쪽에서 서술 "the message counts as processed, the work never happened, and redelivery is refused because the inbox row is already there"

한 결함이 두 파일에 기록돼 있고, 그중 하나가 그것을 막는 테스트다. 그리고 그 테스트가 "hermetic: the refusal has to happen before any connection is requested, and the data source below fails the test by being asked for one"이라고 자기 설계를 적는다 — DataSource가 요청받으면 테스트가 실패하도록 만들어 검사 순서까지 고정한다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-294 command evidence/raw/294-bounded-purge-never-called.txt 두 포트의 bounded 오버로드 선언과 이유, 두 구현의 SQL, 시그니처 9회 등장이 전부 비호출, 두 cleanup job의 실제 호출, DEFAULT_BATCH_SIZE 단일 등장, 무제한 구현의 SQL, 테스트 fake의 대본, 컨테이너 레인도 무제한 호출 정적 검색
EVD-295 command ./gradlew :messaging:messaging-inbox-jdbc-postgresql:test --rerun-tasks BUILD SUCCESSFUL, 25 / 0 / 0. InboxPostgresIT 6개 포함 Testcontainers 환경 의존

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 복합 PK가 중복 제거 메커니즘인 이유 — 클래스 javadoc + migration 주석
  • 예약이 호출자 트랜잭션에 참여해야 하는 이유와 이전 결함 — reserve(Connection,…) javadoc
  • 세 검사가 커넥션 요청 전에 일어나야 하는 이유 — requireActiveTransaction javadoc + 테스트 javadoc
  • 트랜잭션 경계를 호출자에게 위임하는 이유 — IdempotentConsumer javadoc
  • 중복이 오류가 아닌 이유 — 같은 javadoc + duplicateIsSuccess 주석
  • 예외를 감싸되 삼키지 않는 이유 — 인라인 주석
  • 안전계수가 곱셈인 이유 — InboxRetentionPolicy javadoc
  • 정책을 첫 삭제 전에 검증하는 이유 — InboxCleanupJob javadoc
  • 실 DB 인증이 필요한 이유 — build.gradle 주석

추론

  • 두 cleanup job이 같은 형태로 무제한 오버로드를 부르는 것은 bounded 오버로드가 나중에 추가되고 호출부가 갱신되지 않았기 때문이다 → 추론. 두 곳의 동일한 형태는 관측이고 인과는 추론이다.
  • CLAIMED_ELSEWHERE가 생성되지 않는 것은 포트가 boolean을 반환하기 때문이다 → 관측에 가까운 추론. 반환 타입은 관측이다.
  • consumer_id 길이 검증이 없는 것이 의도인지 → 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 6개 타입 542줄과 migration 전문
  • 25개 테스트가 통과하고 컨테이너 레인 6개가 실 PostgreSQL에서 돈다는 것
  • bounded purge 오버로드가 두 포트·두 구현에 있고 호출 지점이 0이라는 것
  • 두 cleanup job이 무제한 오버로드를 부르고 DEFAULT_BATCH_SIZE가 죽은 상수라는 것
  • cleanupDeletesInBoundedBatches가 대본 fake 위에서 통과한다는 것
  • InboxResult 세 값 중 하나만 production에서 생성된다는 것과 그 구조적 이유
  • 세 겹 트랜잭션 검사와 그것이 막는 이전 결함

확인하지 못한 것

  • PostgreSQL의 ON CONFLICT DO NOTHING이 미커밋 충돌 행에 대해 대기하는지 즉시 0을 반환하는지. CLAIMED_ELSEWHERE 상황의 발생 가능성이 여기에 달려 있고, 이 저장소의 테스트가 그것을 재현하지 않는다.
  • InboxRepository bean을 누가 만드는지 — starter leaf가 소유한다.
  • consumer_id가 160자를 넘는 배포가 있는지.
  • 무제한 DELETE가 실제 규모의 테이블에서 얼마나 오래 락을 잡는지 — 측정하지 않았다.
  • InboxCleanupJob을 스케줄링하는 주기 — starter가 소유한다.

17. 손볼 것

P1 — bounded purge가 구현돼 있고 호출되지 않아, cleanup이 스스로 막겠다고 한 장애를 일으킨다
  • 사실. InboxRepository·OutboxRepository 둘 다 purge*Before(Instant, int) 오버로드를 선언하고, JdbcInboxRepository:141·JdbcOutboxRepository:486LIMIT + FOR UPDATE SKIP LOCKED로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 선언 2 + 구현 2 + 테스트 fake override 5이고 호출 지점이 0이다. InboxCleanupJob:56OutboxCleanupJob:50이 무제한 오버로드를 부른다. InboxCleanupJob.DEFAULT_BATCH_SIZE = 1_000은 자기 선언 한 줄만 존재한다.
  • 근거. evidence/raw/294 §C·§D·§E.
  • 왜 문제인가. InboxCleanupJob의 javadoc이 스스로 적는다 — "A single unbounded DELETE over a table that has been accumulating for weeks holds locks long enough to block the very reservations the inbox exists to serve, so the cleanup would cause the outage it is meant to prevent." 실행되는 코드가 정확히 그 문장이 서술하는 동작이다. OutboxRepository의 bounded 오버로드 javadoc은 한 발 더 나간다 — "The cleanup jobs describe themselves as bounded by batch size; this is the parameter that makes that true." 그 파라미터를 아무도 넘기지 않는다. 그리고 두 leaf가 동일한 형태로 그렇다.
  • 왜 P1인가. 두 leaf 다 runtime_memberships: ["app-bootstrap"]이고 두 cleanup job이 starter에서 bean으로 만들어진다(MessagingReliabilityAutoConfigurationinboxCleanupJob·outboxCleanupJob). 즉 출하 구성에서 실행되는 경로이며, 백로그가 쌓인 뒤 첫 스윕에서 발현한다. 다른 미배선 발견들과 성격이 다르다.
  • 확인 방법. evidence/raw/294 재실행. 또는 git grep -n -E 'purge(Processed|Published)Before\s*\([^)]*,' -- 'src/**/*.java'로 호출 지점이 없음을 확인.
  • 후보. 두 job이 bounded 오버로드에 배치 크기를 넘기게 한다 — InboxCleanupJob은 이미 DEFAULT_BATCH_SIZE를 갖고 있다.
  • 다음 단계. CASE 후보. 정적 재현이 완결되고, "장치는 있고 회로가 닫히지 않았다"의 변형 중 닫히지 않은 회로가 실행 경로 위에 있는 유일한 사례다. messaging-outbox-jdbc-postgresql leaf와 공동 소유.
P2 — 속성을 이름으로 주장하는 테스트가 그 속성을 보일 수 없는 fake 위에서 통과한다
  • 사실. InboxOperationsTest.cleanupDeletesInBoundedBatchesInMemoryInbox(List.of(1000, 500))에 대해 removed == 1500cutoffs.hasSize(3)을 단언한다. 그 fake의 무제한 메서드는 미리 준 목록을 순서대로 반환하는 대본이고 아무것도 삭제하거나 제한하지 않는다. bounded 오버로드는 fake에도 있지만 job이 부르지 않아 실행되지 않는다.
  • 근거. evidence/raw/294 §G.
  • 왜 문제인가. 이 테스트가 통과로 증명하는 것은 "0을 받을 때까지 루프를 돈다"이고 이름이 주장하는 "배치로 제한된다"가 아니다. 1000·500은 배치처럼 보이는 숫자다. P1이 이 테스트를 통과한 채로 존재할 수 있었던 이유다. 그리고 컨테이너 레인(InboxPostgresIT.retentionRemovesOldRows)도 무제한 오버로드를 한 행에 대해 부르므로 실 DB에서도 드러나지 않는다.
  • 확인 방법. evidence/raw/294 §G·§H.
  • 후보. fake의 무제한 메서드가 실제로 컬렉션에서 삭제하게 하고, bounded 메서드가 limit를 존중하게 한다. 그러면 테스트가 P1을 잡는다.
  • 다음 단계. CASE 후보 + REFERENCE 후보. messaging-transport-spi §10.2(enum 순서를 단언하는 종료 테스트)와 같은 계열이고, "이름이 주장하는 속성을 fake가 표현할 수 있는지 먼저 확인한다"가 재사용 가능한 기준이다.
P2 — SQL 실패가 재시도 불가로 분류된다
  • 사실. INBOX_RESERVE_FAILED·INBOX_QUERY_FAILED·INBOX_PURGE_FAILED 셋 다 MessagingConfigurationException이고, 그 예외의 카테고리는 CONFIGURATION, retryable = false다.
  • 근거. JdbcInboxRepository.java:77-80, 134-137, 165-168, 179-182. MessagingConfigurationException.javaCATEGORY 상수.
  • 왜 문제인가. SQLException의 원인 대부분은 구성 오류가 아니라 일시적 인프라다 — 연결 끊김, 데드락, 락 타임아웃, 커넥션 풀 고갈. FailureCategory는 "the stable classification a retry engine, DLQ router, and dashboard all agree on"이고 retryable = false는 재시도 엔진이 즉시 파킹한다는 뜻이다. 같은 leaf의 INBOX_ACTION_FAILEDTRANSIENT_INFRASTRUCTURE/retryable = true로 정확히 분류된다 — 같은 파일 안에서 기준이 갈린다.
  • 확인 방법. 네 catch 블록과 MessagingConfigurationException의 카테고리 대조.
  • 후보. SQL 실패를 MessageBrokerUnavailableException류(또는 TRANSIENT_INFRASTRUCTURE 카테고리를 갖는 예외)로 바꾸고, 진짜 구성 오류(테이블 없음 등)만 CONFIGURATION으로 남긴다.
  • 다음 단계. CASE 후보. 재시도 정책이 실제로 갈리는 지점이다.
P3 — 세 갈래 판정이 포트의 boolean에서 두 갈래로 접힌다
  • 사실. InboxResult가 세 값과 isSafeToSettle()을 갖는데 production은 APPLIED만 만든다. InboxRepository.reserveboolean을 반환하므로 ALREADY_APPLIEDCLAIMED_ELSEWHERE가 같은 false로 들어온다. TransactionalInboxHandler는 그 경우 HandleResult.success()를 반환한다 — 정산한다.
  • 근거. evidence/raw/294 범위 밖이나 §12.3(a)의 검색 결과. InboxResult javadoc.
  • 왜 문제인가. InboxResult javadoc이 세 값이 필요한 이유로 정확히 그 정산을 든다 — "would settle a message whose effect is still only half-written by another instance". 다만 그 상황이 PostgreSQL에서 실제로 발생 가능한지 확인하지 않았다(§16). ON CONFLICT DO NOTHING이 미커밋 충돌에 대해 대기한다면 CLAIMED_ELSEWHERE는 도달 불가능한 상태이고 enum이 과설계인 것이며, 즉시 0을 반환한다면 이것은 실제 결함이다.
  • 확인 방법. 두 커넥션에서 같은 (message, consumer)를 예약하고 한쪽을 커밋하지 않은 채 다른 쪽의 executeUpdate() 반환을 관측한다 — InboxPostgresIT에 추가 가능하다.
  • 후보. 먼저 확인한다. 발생 가능하면 포트 반환 타입을 InboxResult로 바꾼다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 확인하지 않은 DB 동작에 걸린다.
P3 — consumer_id 길이 제약이 애플리케이션 층에 없다
  • 사실. migration이 consumer_id VARCHAR(160)이다. IdempotentConsumer·TransactionalInboxHandler·JdbcInboxRepository가 공백만 거절하고 길이를 보지 않는다.
  • 근거. V2__messaging_inbox.sql:10, 세 클래스의 검증.
  • 왜 문제인가. 긴 consumerId가 DB에서 SQLException으로 실패하고, §17의 다른 항목대로 그것이 INBOX_RESERVE_FAILED/CONFIGURATION/retryable=false가 된다 — 즉 설정 실수가 메시지 파킹으로 나타난다. messaging-core-api의 값 객체들이 바이트 상한을 생성자에서 강제하는 것(그쪽 §4.5)과 대비된다.
  • 확인 방법. 161자 consumerId로 reserve 호출.
  • 후보. consumerId를 값 객체로 만들거나 길이 검증을 추가한다.
  • 다음 단계. REFERENCE 후보(컬럼 폭은 애플리케이션 검증과 짝을 이룬다).
P3 — 보존 규칙이 세 곳에 있고 공식이 다르다
  • 사실. InboxRepository javadoc(강제 없음), 이 leaf InboxRetentionPolicy(× 2.0, InboxCleanupJob 생성 시에만), messaging-claim-check ClaimCheckPolicy(brokerRetention + maxRedeliveryWindow, 항상).
  • 근거. 세 위치.
  • 왜 문제인가. 같은 종류의 시간 관계를 곱셈과 덧셈으로 다르게 표현하고, 강제 시점도 다르다. 그리고 이 leaf의 validate()cleanup job을 만들 때만 불린다 — cleanup을 배선하지 않은 배포는 보존 검사를 받지 않는다.
  • 확인 방법. 세 위치의 공식 대조.
  • 후보. 공식을 하나로 정하고 정책 생성자에서 강제한다(claim-check처럼).
  • 다음 단계. REFERENCE 후보(같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다).
확인된 설계(문제 아님)
  • 복합 PK + ON CONFLICT DO NOTHING의 영향 행 수를 판정으로 쓰는 것
  • 트랜잭션 경계를 호출자에게 위임하고 그 위임이 지켜졌는지 런타임에 세 겹으로 확인하는 것
  • 세 검사가 커넥션 요청 전에 일어나고, 그것을 DataSource가 요청받으면 실패하는 테스트로 고정한 것
  • 다른 DataSource에 묶인 트랜잭션을 거절하는 것
  • action 예외를 감싸되 삼키지 않아 롤백이 예약까지 되돌리게 하는 것
  • 중복을 성공으로 보고해 완료된 작업을 DLQ로 보내지 않는 것
  • 안전계수를 곱셈으로 둔 것과 그 이유
  • 정책을 첫 삭제 전에 검증하는 것
  • 실 PostgreSQL 컨테이너 레인이 기본 test 태스크에서 도는 것과, 롤백 경로를 그 레인이 검증하는 것

Source anchors

id kind path revision what it proves limitations
MIJ-001 registry src/config/architecture/modules.json 21234e38 deps 2개, memberships ["app-bootstrap"] 선언
MIJ-002 build messaging-inbox-jdbc-postgresql/build.gradle same 실 DB 인증 의도
MIJ-003 code .../inbox/JdbcInboxRepository.java 전문 same §4.1 세 검사, 두 오버로드의 SQL 무제한만 호출됨
MIJ-004 code .../inbox/IdempotentConsumer.java same §4.2 트랜잭션 위임
MIJ-005 code .../inbox/TransactionalInboxHandler.java same §4.3 세 금지와 예외 캐리어 APPLIED만 생성
MIJ-006 code .../inbox/InboxRetentionPolicy.java same §4.4 곱셈 안전계수 validate() 호출 시점 제한
MIJ-007 code .../inbox/InboxCleanupJob.java same §4.5 선언과 구현의 불일치
MIJ-008 migration .../db/migration/messaging/V2__messaging_inbox.sql same 복합 PK, 인덱스, 컬럼 폭
MIJ-009 test InboxPostgresIT (6) same 실 PostgreSQL 롤백·중복·보존 bounded 스윕 미검증
MIJ-010 test JdbcInboxTransactionRequirementTest (4) same 세 거절이 커넥션 전에
MIJ-011 test IdempotentConsumerTest (6), InboxOperationsTest (9) same §10 표 fake가 대본(§10.2)
MIJ-012 cross-leaf code messaging-reliability-api/.../InboxRepository.java:36-52, OutboxRepository.java:132-151 same 두 오버로드 선언과 bounded의 존재 이유 해당 leaf SSOT가 소유
MIJ-013 cross-leaf code messaging-outbox-jdbc-postgresql/.../OutboxCleanupJob.java:50, JdbcOutboxRepository.java:486 same 같은 결함이 형제 leaf에도 해당 leaf SSOT가 소유
EVD-294 command evidence/raw/294-bounded-purge-never-called.txt same §12.1 전부 정적 검색
EVD-295 command ./gradlew :messaging:messaging-inbox-jdbc-postgresql:test --rerun-tasks same 25 / 0 / 0, 컨테이너 6개 포함 Testcontainers 환경 의존

A19-MESSAGING-KAFKA-SHARE-EXPERIMENTAL. messaging-kafka-share-experimental

분석 중에는 messaging/MESSAGING-KAFKA-SHARE-EXPERIMENTAL.md 파일이었다. 546줄.

messaging-kafka-share-experimental 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-kafka-share-experimental SSOT owner: messaging-kafka-share-experimental integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-kafka-share-experimental
  • canonical state analysisFile: §A19-MESSAGING-KAFKA-SHARE-EXPERIMENTAL
  • source path: src/messaging/messaging-kafka-share-experimental
  • registry allowed_dependencies: ["messaging-core-api", "messaging-policy", "messaging-transport-spi", "messaging-kafka"]
  • registry runtime_memberships: [] — build-only / incubating
숫자
항목
production Java 파일 4
production LOC 190 — messaging family에서 가장 작다
패키지 1 (dev.caskeleton.messaging.kafka.share)
test 파일 1
test 메서드(실행 확인) 6
선언된 외부 의존성 1 (org.apache.kafka:kafka-clients, implementation)
실제 사용된 외부 의존성 0(§12.4)

네 타입:

타입 종류 LOC leaf 밖 참조
KafkaShareGroupRegistrar class 88 0
KafkaShareProfileValidator class 42 0
KafkaShareProfile record 33 0
KafkaShareWorkQueueCapability class 27 0
Coverage ledger
scope/file group count disposition reason
src/main/java/** (4) 4 FULL_READ 전 파일 본문 확인
src/test/java/** (1) 1 FULL_READ 6개 테스트 확인
build.gradle 1 FULL_READ 10줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

Kafka Share Group(KIP-932, 경쟁 소비자 work queue)을 실험적 어댑터로 감싼다. runtime_memberships: []이고 이름 자체가 -experimental이다.

이 leaf의 실질은 거절이다. 190줄 중 실제 동작을 하는 코드는 거의 없고, 세 가지를 거절한다.

거절 코드 이유
비활성 상태의 사용 KAFKA_SHARE_DISABLED experimental이 기본 켜지지 않게
순서 보장 목적지 IllegalArgumentException share group이 순서를 줄 수 없음
pause/resume KAFKA_SHARE_NO_PAUSE/_NO_RESUME 일시정지할 파티션 할당이 없음

핵심 진술이 validator javadoc에 있다.

// KafkaShareProfileValidator.java:10-17
 * <p>A share group hands individual records to competing consumers and acknowledges them
 * individually. That is a work queue, and it is fundamentally incompatible with partition ordering:
 * two consumers in the same share group can process records from one partition concurrently and
 * finish in either order. Configuring an ordered destination on a share group would therefore
 * advertise a guarantee the broker is not providing, so it is refused rather than degraded.
 *
 * <p>The adapter is also off unless explicitly enabled, so an Experimental capability cannot drift
 * into a Stable deployment by default.

두 번째 문단이 이 저장소의 experimental 정책을 한 문장으로 담는다 — 기본 꺼짐이 drift 방지 수단이다.


2. 의존성과 런타임 배선

들어오는 것(project): messaging-core-api, messaging-policy, messaging-transport-spi, messaging-kafka — 넷 다 api.

들어오는 것(vendor): org.apache.kafka:kafka-clients(implementation) — 어떤 소스도 import하지 않는다(§12.4).

나가는 것: 없다. 어떤 leaf의 allowed_dependencies에도 이 leaf가 없고 starter 목록에도 없다.

런타임 배선: 없음. runtime_memberships: []. bean 없음(Spring 주석 0개).

소비자 없음·membership 없음·조립 없음의 삼중 정합이다 — messaging-schema-avro·messaging-schema-protobuf와 같은 형태이고, incubating leaf의 올바른 상태다.

messaging-policymessaging-kafka 의존이 실제로 쓰이는가.

의존 사용
messaging-core-api OrderingScope, DestinationName, MessagingCapabilities, MessagingCapabilityUnavailableException사용
messaging-transport-spi TransportConsumerRegistration, TransportConsumerSpec사용
messaging-policy 어떤 타입도 import하지 않음 — 미사용
messaging-kafka 어떤 타입도 import하지 않음 — 미사용

네 project 의존 중 둘, 벤더 의존 하나가 미사용이다. §17.


3. 패키지/컴포넌트 지도

KafkaShareProfile (record)
  destination · shareGroup · orderingScope · enabled · maxDeliveryCount
        ↓
KafkaShareProfileValidator.validate(profile)
  ├── !enabled            → MessagingCapabilityUnavailableException(KAFKA_SHARE_DISABLED)
  └── orderingScope != NONE → IllegalArgumentException
        ↓
KafkaShareGroupRegistrar.register(profile, spec)
  └── ShareRegistration implements TransportConsumerRegistration
        ├── pause(scope)  → failedFuture(KAFKA_SHARE_NO_PAUSE)
        ├── resume(scope) → failedFuture(KAFKA_SHARE_NO_RESUME)
        ├── isActive()    → true until close()
        └── close()       → active = false

KafkaShareWorkQueueCapability.capabilities() → MessagingCapabilities(12 booleans)

4. 계약·불변식·상태 모델

4.1 KafkaShareProfile

다섯 필드. 생성자가 shareGroup 공백과 maxDeliveryCount < 1을 거절한다.

maxDeliveryCount가 javadoc에서 "how many times a record may be re-acquired before it is released"라고 정의된다 — Share Group의 재획득 한계다. 이 필드를 읽는 코드가 이 leaf에 없다. validator도 registrar도 쓰지 않는다.

4.2 KafkaShareProfileValidator — 두 거절
if (!profile.enabled()) {
  throw new MessagingCapabilityUnavailableException(
      "KAFKA_SHARE_DISABLED",
      "the Kafka Share Group adapter is experimental and disabled unless "
          + "backend.messaging.experimental.kafka-share=true");
}
if (profile.orderingScope() != OrderingScope.NONE) {
  throw new IllegalArgumentException(
      "a Kafka share group cannot provide ordered delivery: " + profile.destination().value());
}

두 거절의 예외 타입이 다르다. 첫째는 MessagingCapabilityUnavailableException(카테고리 CONFIGURATION, 안정 코드 있음), 둘째는 IllegalArgumentException(코드 없음). 둘 다 설정 오류인데 하나만 플랫폼 실패 어휘를 쓴다. §17.

에러 메시지가 프로퍼티 키를 직접 적는다backend.messaging.experimental.kafka-share=true. 그 키를 읽는 코드가 이 저장소에 없다(§12.4).

4.3 KafkaShareGroupRegistrar — spec을 받고 쓰지 않는다
public TransportConsumerRegistration register(
    KafkaShareProfile profile, TransportConsumerSpec spec) {
  Objects.requireNonNull(spec, "spec must not be null");
  validator.validate(profile);
  return new ShareRegistration(profile);
}

specnull 검사만 받는다. ShareRegistrationprofileAtomicBoolean active 둘만 갖는다.

TransportConsumerSpec(DestinationProfile profile, Function<TransportDelivery, CompletionStage<Void>> sink)이고, sink가 플랫폼이 전달마다 부르는 콜백이다(messaging-transport-spi §4.5). 그 sink가 저장되지 않으므로 어떤 메시지도 전달되지 않는다.

Kafka 소비자도 만들어지지 않는다 — kafka-clients를 import하는 코드가 없다.

register(...)아무것도 등록하지 않고 isActive() == true인 객체를 반환한다. §17.

4.4 ShareRegistration — pause/resume은 실패 stage
@Override
public CompletionStage<Void> pause(String scope) {
  return CompletableFuture.failedFuture(
      new MessagingCapabilityUnavailableException("KAFKA_SHARE_NO_PAUSE", ...));
}

registrar javadoc이 이유를 적는다.

// :12-15
 * <p>Pause and resume are refused rather than silently ignored. A share group has no partition
 * assignment to pause, so accepting the call would let a retry policy that depends on pausing
 * appear to work while doing nothing.

예외를 던지지 않고 실패한 CompletionStage를 반환한다TransportConsumerRegistration.pause의 반환 타입이 CompletionStage<Void>이므로 비동기 계약을 지킨다. messaging-runtime-coreDefaultDeliveryProcessor.OneShotSettlement가 이중 정산을 failedFuture로 보고하는 것과 같은 규율이다.

이 거절이 messaging-policyRetryMode.PAUSE_PARTITION과 맞물린다 — 그 모드를 share group 목적지에 설정하면 DefaultRetryDecisionEnginePauseAndRetry를 고르고 이 registration이 그것을 거절한다. 두 leaf가 같은 사실을 양쪽에서 안다.

close()active를 false로 바꾸는 것 외에 아무것도 하지 않는다 — 해제할 자원이 없기 때문이다.

4.5 KafkaShareWorkQueueCapability — 12개 boolean
return new MessagingCapabilities(
    true, true, true, false, false, false, false, false, false, false, false, false);

MessagingCapabilities의 필드 순서에 대입하면:

# capability
1 brokerAcknowledgement true
2 replicationOrPersistenceEvidence true
3 perMessageSettlement true
4 batchSettlement false
5 orderedStream false
6 keyedOrdering false
7 replay false
8 delayedDelivery false
9 brokerTransaction false
10 deduplicatedPublish false
11 nativeDeadLetter false
12 topologyManagement false

javadoc이 요약한다 — "Per-record settlement, yes. Ordering, replay, and transactions, no — a share group gives up exactly those to gain competing-consumer throughput."

세 true가 정확히 3·1·2번이고 javadoc이 "per-record settlement"만 언급한다. 1·2번(브로커 ack, 복제 증거)은 언급되지 않는다.

선언 목적도 적혀 있다 — "Declared as a capability rather than assumed, so that the shared validators refuse an ordered or replayed destination on this adapter before a message is ever produced." 즉 messaging-policy의 검증기와 DefaultRetryDecisionEngine이 이 값을 읽을 것을 전제한다. 그 전달 경로가 없다(§12.1).


5. 주요 실행 경로

등록: registrar.register(profile, spec)validator.validate(profile) → 통과하면 ShareRegistration(profile) 반환 → 이후 아무 일도 일어나지 않는다

pause: registration.pause(scope) → 즉시 실패 stage

이 leaf에 메시지가 흐르는 경로가 없다.


6. 실패 경로와 복구/번역

코드 예외 카테고리 조건
KAFKA_SHARE_DISABLED MessagingCapabilityUnavailableException CONFIGURATION enabled == false
(코드 없음) IllegalArgumentException orderingScope != NONE
KAFKA_SHARE_NO_PAUSE MessagingCapabilityUnavailableException CONFIGURATION pause(...)
KAFKA_SHARE_NO_RESUME MessagingCapabilityUnavailableException CONFIGURATION resume(...)
(코드 없음) IllegalArgumentException shareGroup 공백, maxDeliveryCount < 1

MessagingCapabilityUnavailableException의 javadoc이 이 leaf의 태도와 정확히 일치한다 — "Thrown instead of quietly degrading. Downgrading … ordered delivery to unordered, produces a system that looks healthy right up to the moment the guarantee actually mattered."


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

ShareRegistration.activeAtomicBoolean이다. close()set(false)이고 CAS가 아니므로 두 번 닫아도 무해하다(멱등).

KafkaShareProfileValidator·KafkaShareWorkQueueCapability는 상태가 없다. KafkaShareGroupRegistrar는 validator 참조 하나만 갖는다.

수명주기 참여 없음 — TransportConsumerRegistrationAutoCloseable이지만 이 구현은 닫을 자원을 갖지 않는다.


8. 설정·기능 플래그·환경 차이

항목
프로퍼티 키(에러 메시지에만 등장) backend.messaging.experimental.kafka-share
enabled KafkaShareProfile의 필드 — 호출자가 채운다

그 프로퍼티를 읽는 코드가 저장소에 없다. enabledKafkaShareProfile 생성자 인자이고 그 profile을 만드는 production 코드도 없다. 즉 키는 문서로만 존재한다. §17.

상수 없음.


9. 퍼시스턴스/외부 시스템 세부

없다. Kafka Share Group을 감싼다고 선언하지만 Kafka 클라이언트를 사용하지 않는다.

build.gradleimplementation 'org.apache.kafka:kafka-clients'를 선언하고 import org.apache.kafka가 소스에 0건이다(§12.4).


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-kafka-share-experimental:test. BUILD SUCCESSFUL, 6 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
KafkaShareProfileValidatorTest 6 두 거절 조건과 통과 조건 registrar·capability가 검증되지 않음

네 타입 중 하나만 테스트된다.

  • KafkaShareGroupRegistrar — 테스트 없음. register가 spec을 무시하는 것, pause/resume이 실패 stage를 반환하는 것, closeisActive를 바꾸는 것이 전부 미검증
  • KafkaShareWorkQueueCapability — 테스트 없음. 12개 boolean 중 어느 것도 단언되지 않음
  • KafkaShareProfile — 생성자 거절 둘이 validator 테스트를 통해 간접적으로만

messaging-schema-avro가 3개 테스트 클래스로 2개 production 타입을 덮는 것과 대비된다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies 네 project 의존 — 미사용 둘을 포함해 통과한다(허용 목록은 상한이지 하한이 아니다)
verifyRuntimeModuleMembership [] — 런타임 편입 없음이 강제됨
vendor api 규칙(src/messaging/CLAUDE.md:40-43) public 시그니처에 Kafka 타입이 없으므로 implementation이 맞다. 다만 아예 쓰이지 않는다(§12.4)
SecretLeakStaticScanTest(observability leaf) 이 leaf 소스도 스캔 대상
ArchUnit 전용 규칙 없음

첫 행이 이 leaf의 §17 항목 중 하나다 — allowed_dependencies실제 사용을 요구하지 않는다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt §C·§D·§E.

12.1 Public surface reachability

네 타입 전부 leaf 밖 참조 0이다.

KafkaShareGroupRegistrar           0
KafkaShareProfile                  0
KafkaShareProfileValidator         0
KafkaShareWorkQueueCapability      0

runtime_memberships: [], starter 미포함, 조립 0건 — 삼중 정합이다. messaging-schema-avro·messaging-schema-protobuf와 같은 상태이고, incubating leaf가 이래야 하는 형태다.

messaging-claim-check·messaging-cloudevents와 대비된다 — 그 둘은 소비자 0인데 membership이 있다.

KafkaShareWorkQueueCapability의 0이 다른 의미를 갖는다. 이 클래스의 javadoc은 "so that the shared validators refuse an ordered or replayed destination on this adapter before a message is ever produced"라고 한다. 즉 공유 검증기가 이 값을 읽을 것을 전제한다. messaging-policyRetryContext.capabilitiesDefaultRetryDecisionEngine이 그 소비자인데, 그것에 이 값을 넘기는 경로가 없다. MessagingTransport.capabilities(DestinationName)가 그 경로여야 하는데 이 leaf는 MessagingTransport를 구현하지 않는다.

12.2 Conditional sibling comparison

Spring 주석 0개, bean 없음.

MessagingTransport 구현 sibling과의 비교가 유의미하다.

어댑터 leaf MessagingTransport 구현 membership
messaging-kafka o (KafkaMessagingTransport) ["app-bootstrap"]
messaging-rabbit o (RabbitMessagingTransport) ["app-bootstrap"]
messaging-pulsar-experimental o (PulsarMessagingTransport) []
messaging-nats-experimental o (NatsJetStreamTransport) []
messaging-kafka-share-experimental x []

네 형제 어댑터가 전부 SPI를 구현하고 이 leaf만 구현하지 않는다. 두 experimental 형제(pulsar, nats)도 구현한다. 그래서 이 leaf는 "experimental이라서 미완"이 아니라 형제와 다른 형태다 — TransportConsumerRegistration만 부분 구현하고 MessagingTransport는 건드리지 않는다.

결과: capability 선언(§12.1)도, 발행 경로도, 소비 경로도 플랫폼에 연결될 지점이 없다.

12.3 Duplicate mechanism sweep

(a) 순서 거절이 두 곳에 있다

위치 검사
이 leaf KafkaShareProfileValidator orderingScope != NONE → 거절
messaging-policy DestinationProfileValidator:78-82 orderingScope == DESTINATION && consumer.concurrency > 1 → 거절
messaging-policy DestinationProfileValidator:83-87 isOrdered() && maxInFlightPerOrderingUnit > 1 → 거절

세 검사가 같은 관심사(순서와 동시성의 양립 불가)를 다룬다. 이 leaf의 것이 가장 강하다 — 순서 자체를 금지한다. policy 쪽은 순서를 허용하되 동시성을 1로 묶는다.

두 정책이 만나는 지점이 없다 — 이 leaf가 DestinationProfile을 받지 않고 자기 KafkaShareProfile을 쓴다. 즉 목적지 프로파일 하나가 두 검증기를 통과하는 경로가 없다. 중복이 아니라 연결되지 않은 두 모델이다.

(b) enabled 플래그 패턴

experimental leaf 셋(kafka-share, pulsar, nats) 중 이 leaf만 enabled를 profile 필드로 갖는다. 나머지 둘의 활성화 방식은 각 leaf SSOT가 답한다.

(c) MessagingCapabilities 선언이 어댑터마다

각 어댑터가 자기 capability 집합을 선언한다. 이 leaf는 정적 메서드 하나, messaging-kafkaKafkaMessagingTransport.CAPABILITIES 상수. 형태가 다르지만 중복 경쟁은 아니다 — 각자 자기 브로커를 서술한다.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
build.gradle: kafka-clients 의존 import org.apache.kafka 0건 미사용 의존
registry: messaging-policy·messaging-kafka 의존 두 패키지에서 import 0건 미사용 의존
KafkaShareProfileValidator 에러 메시지: backend.messaging.experimental.kafka-share=true 그 키를 읽는 코드 0건 미실현
KafkaShareWorkQueueCapability javadoc: "the shared validators refuse … before a message is ever produced" capability를 검증기로 넘기는 경로 없음 미실현
KafkaShareGroupRegistrar javadoc: "Registers a share group consumer" 소비자를 만들지 않음 불일치
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 실제로 [] 이 leaf에 한해 참

다섯 번째가 이 leaf의 가장 무거운 drift다 — 클래스 이름과 메서드 이름이 하지 않는 일을 서술한다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

이 leaf의 javadoc에 이전 결함 서술이 없다. 다른 messaging leaf 대부분이 "X used to …" 형태의 기록을 갖는 것과 대비된다.

대신 막으려는 것을 셋 적는다.

위치 막으려는 것
KafkaShareProfileValidator 순서 목적지를 share group에 설정 → 브로커가 주지 않는 보장을 광고
같은 곳 experimental이 기본 켜져 Stable 배포로 drift
KafkaShareGroupRegistrar pause를 조용히 무시 → pause에 의존하는 retry 정책이 동작하는 것처럼 보이며 아무것도 하지 않음

세 번째가 이 leaf에서 가장 성숙한 판단이다 — 거절이 무시보다 낫다는 원칙이고, messaging-core-apiMessagingCapabilityUnavailableException javadoc과 같은 계열이다.

역설적으로 그 원칙이 register(...)에는 적용되지 않았다 — spec을 받아 무시하고 성공을 반환한다(§17).


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-290 command evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt §C·§D·§E 네 타입 참조 0, kafka-clients 선언과 import 0(exit=1), register가 spec을 무시하는 코드와 ShareRegistration 필드 정적 검색
EVD-293 command ./gradlew :messaging:messaging-kafka-share-experimental:test --rerun-tasks BUILD SUCCESSFUL, 6 / 0 / 0 validator만 검증

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • share group이 순서와 양립 불가인 이유 — KafkaShareProfileValidator javadoc
  • experimental이 기본 꺼짐인 이유 — 같은 javadoc
  • pause/resume을 무시하지 않고 거절하는 이유 — KafkaShareGroupRegistrar javadoc
  • capability를 선언으로 두는 이유 — KafkaShareWorkQueueCapability javadoc
  • share group이 포기한 것(순서·replay·트랜잭션)과 얻은 것(경쟁 소비자 처리량) — 같은 javadoc

추론

  • register가 spec을 쓰지 않는 것이 미완인지 의도인지 → 미상. 다른 형제 어댑터는 전부 실제 소비자를 만든다.
  • kafka-clients·messaging-policy·messaging-kafka 의존이 선언만 된 이유 → 추론. 완성된 구현을 상정하고 미리 선언한 것으로 보인다.
  • maxDeliveryCount를 읽는 코드가 없는 이유 → 미상. 같은 추론이 적용된다.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 4개 타입 190줄 전문
  • 6개 테스트가 통과하고 validator만 덮는다는 것
  • 네 타입 전부 참조 0이고 membership []과 정합한다는 것
  • kafka-clients 의존 선언과 import 0건
  • messaging-policy·messaging-kafka 의존이 사용되지 않는다는 것
  • register(...)TransportConsumerSpec을 null 검사만 하고 버린다는 것
  • 형제 어댑터 넷이 전부 MessagingTransport를 구현하고 이 leaf만 하지 않는다는 것

확인하지 못한 것

  • 이 leaf를 완성할 계획이 있는지 — 커밋이 대량 커밋뿐이고 기록이 없다.
  • Kafka Share Group(KIP-932)이 이 저장소가 고정한 Kafka 버전에서 사용 가능한지 — kafka-clients 버전이 lockfile에 있으나 확인하지 않았다.
  • backend.messaging.experimental.kafka-share 키가 어딘가 문서화돼 있는지 — docs/messaging/experimental-policy.md가 후보다.
  • maxDeliveryCount가 어떤 값을 갖도록 의도됐는지.

17. 손볼 것

P2 — "등록"이 아무것도 등록하지 않고 성공을 반환한다
  • 사실. KafkaShareGroupRegistrar.register(profile, spec)specObjects.requireNonNull로만 처리하고 버린다. ShareRegistrationprofileAtomicBoolean 둘만 갖는다. Kafka 소비자가 만들어지지 않고(import org.apache.kafka 0건), spec.sink가 저장되지 않으므로 어떤 전달도 일어나지 않는다. 반환된 registration은 isActive() == true를 보고한다.
  • 근거. evidence/raw/290 §D·§E.
  • 왜 문제인가. 같은 클래스의 javadoc이 pause를 조용히 무시하는 것을 거절한 이유로 "would let a retry policy that depends on pausing appear to work while doing nothing"을 든다. register 자체가 정확히 그 형태다 — 성공을 반환하고 아무것도 하지 않으며 isActive()가 true다. 오늘 호출자가 없으므로 사고는 아니지만, 이 leaf를 배선하는 사람이 가장 먼저 부를 메서드다.
  • 확인 방법. evidence/raw/290 §E 재실행.
  • 후보. (a) 실제 share group 소비자를 만든다. (b) 미구현임을 명시하고 MessagingCapabilityUnavailableException으로 거절한다 — 이 leaf 자신의 원칙과 일관된다. (c) register를 제거하고 validator와 capability만 남긴다.
  • 다음 단계. CASE 후보. "무시보다 거절"을 명시한 클래스가 자기 주 메서드에서는 무시한다는 형태가 그 자체로 가치가 있다.
P3 — 선언된 의존 셋이 사용되지 않는다
  • 사실. build.gradleorg.apache.kafka:kafka-clients를 선언하고 import org.apache.kafka가 0건. registry가 messaging-policy·messaging-kafka 의존을 허용하고 두 패키지의 import가 0건.
  • 근거. evidence/raw/290 §D. import 전수.
  • 왜 문제인가. verifyCleanArchitectureDependenciesallowed_dependencies상한으로 검사하므로 미사용 의존을 잡지 못한다. 결과: 이 leaf의 build closure가 실제 필요보다 넓고, messaging-kafka(34파일)와 그 전이 의존이 딸려 온다. 그리고 의존 선언이 "이 leaf가 Kafka를 쓴다"는 인상을 준다.
  • 확인 방법. grep -rn 'import org.apache.kafka\|import dev.caskeleton.messaging.policy\|import dev.caskeleton.messaging.kafka\.' src/messaging/messaging-kafka-share-experimental/src
  • 후보. 구현 전까지 미사용 의존을 제거하거나, 미완 상태임을 build.gradle 주석에 적는다.
  • 다음 단계. REFERENCE 후보(허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다).
P3 — 형제 어댑터 넷이 구현하는 SPI를 이 leaf만 구현하지 않는다
  • 사실. KafkaMessagingTransport·RabbitMessagingTransport·PulsarMessagingTransport·NatsJetStreamTransport가 전부 MessagingTransport를 구현한다. 이 leaf는 TransportConsumerRegistration만 부분 구현한다.
  • 근거. evidence/raw/280 §D(transport-spi probe)와 이 leaf의 소스.
  • 왜 문제인가. KafkaShareWorkQueueCapability가 존재하는 이유("shared validators refuse … before a message is ever produced")가 실현되려면 MessagingTransport.capabilities(DestinationName)를 통해 값이 전달돼야 한다. 그 인터페이스를 구현하지 않으므로 capability는 아무도 읽지 않는 상수다. 두 experimental 형제(pulsar, nats)는 구현하므로 "experimental이라서"가 이유가 되지 않는다.
  • 확인 방법. git grep -n 'implements MessagingTransport' -- 'src/messaging/**/*.java'
  • 후보. MessagingTransport를 구현하거나, capability를 어떻게 전달할지 정한다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 §17 첫 항목("완성할 것인가")에 걸린다.
P3 — 두 거절이 다른 예외 계층을 쓴다
  • 사실. !enabledMessagingCapabilityUnavailableException(안정 코드 KAFKA_SHARE_DISABLED), orderingScope != NONEIllegalArgumentException(코드 없음).
  • 근거. KafkaShareProfileValidator.java:31-40.
  • 왜 문제인가. 둘 다 설정 오류이고 둘 다 시작 시점에 잡힌다. 한쪽만 FailureDescriptor를 갖는다. messaging-securityMessageSecurityValidator(전부 IllegalArgumentException)와 BrokerTlsPolicy(전부 MessagingConfigurationException)가 갈라진 것과 같은 형태다.
  • 확인 방법. 두 throw 문 대조.
  • 후보. 둘 다 MessagingConfigurationException으로 통일하고 안정 코드를 준다.
  • 다음 단계. messaging-security §17의 같은 항목과 함께 REFERENCE 후보(구성 오류는 한 예외 타입과 안정 코드로 보고한다).
P3 — 네 타입 중 하나만 테스트된다
  • 사실. KafkaShareProfileValidatorTest만 존재한다. registrar·capability에 테스트가 없다.
  • 근거. find src/test -name '*Test.java' → 하나.
  • 왜 문제인가. register가 spec을 버리는 것(§17 첫 항목)이 테스트가 있었다면 드러났을 형태다 — sink가 호출되는지 확인하는 테스트가 실패했을 것이다. capability 12개 boolean도 미검증이라 순서를 true로 바꿔도 아무것도 깨지지 않는다.
  • 확인 방법. 테스트 클래스 목록.
  • 후보. registrar와 capability에 테스트를 추가한다.
  • 다음 단계. REFERENCE 후보(leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다) — messaging-claim-check §17과 같은 기준.
P3 — 활성화 프로퍼티 키가 에러 메시지에만 존재한다
  • 사실. backend.messaging.experimental.kafka-share=trueKAFKA_SHARE_DISABLED 메시지에 적혀 있다. 그 키를 읽는 코드가 저장소에 없다.
  • 근거. git grep -n 'kafka-share' -- src → 이 leaf의 문자열 하나.
  • 왜 문제인가. 운영자가 메시지를 보고 그 프로퍼티를 설정해도 효과가 없다. enabledKafkaShareProfile 생성자 인자이고 그 profile을 만드는 production 코드가 없다.
  • 확인 방법. 키 문자열 검색.
  • 후보. 배선될 때 프로퍼티 바인딩을 함께 만들거나, 메시지에서 키를 빼고 "이 profile의 enabled를 설정하라"로 바꾼다.
  • 다음 단계. REFERENCE 후보(에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다) — messaging-claim-check §17의 "use claim check"와 같은 형태.
확인된 설계(문제 아님)
  • 순서 목적지를 degrade하지 않고 거절하는 것과 그 이유
  • experimental을 기본 꺼짐으로 두는 것
  • pause/resume을 조용히 무시하지 않고 실패 stage로 거절하는 것
  • capability를 가정이 아니라 선언으로 두는 것
  • close()가 멱등인 것
  • 소비자 0·membership []·조립 0의 삼중 정합

Source anchors

id kind path revision what it proves limitations
MKS-001 registry src/config/architecture/modules.json 21234e38 deps 4개, runtime_memberships: [] 선언
MKS-002 build messaging-kafka-share-experimental/build.gradle same kafka-clients 선언 사용되지 않음(§12.4)
MKS-003 code .../kafka/share/KafkaShareProfileValidator.java same §4.2 두 거절과 experimental 정책 예외 계층 불일치(§17)
MKS-004 code .../kafka/share/KafkaShareGroupRegistrar.java same §4.3 spec 무시, §4.4 pause 거절 테스트 없음
MKS-005 code .../kafka/share/KafkaShareProfile.java same 다섯 필드와 두 거절 maxDeliveryCount 미사용
MKS-006 code .../kafka/share/KafkaShareWorkQueueCapability.java same 12 boolean과 선언 목적 전달 경로 없음
MKS-007 test KafkaShareProfileValidatorTest (6) same 두 거절과 통과 네 타입 중 하나만
MKS-008 cross-leaf code 4개 *MessagingTransport.java same 형제 넷이 SPI 구현 각 leaf SSOT가 소유
MKS-009 cross-leaf code messaging-policy/.../DestinationProfileValidator.java:78-87 same 연결되지 않은 두 순서 정책 해당 leaf SSOT가 소유
EVD-290 command evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt §C·§D·§E same §12.1·§12.4 정적 검색
EVD-293 command ./gradlew :messaging:messaging-kafka-share-experimental:test --rerun-tasks same 6 / 0 / 0 validator만

A19-MESSAGING-KAFKA. messaging-kafka

분석 중에는 messaging/MESSAGING-KAFKA.md 파일이었다. 443줄.

messaging-kafka 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) — src/main production 34파일 3,427줄 + src/test 24파일 4,087줄 축자 통독 완료. STRUCTURAL_ONLYgradle.lockfile 하나. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-kafka SSOT owner: messaging-kafka integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • runtime_memberships: ["app-bootstrap"] — 출하
  • 등급: Stable · 이 가족에서 실제로 선택 가능한 유일한 브로커(§12.1)
파일 LOC 조립되나
KafkaConsumerRegistrar 621 아니오 — 테스트만
KafkaMessagingTransport 215 예(발행 전용 생성자)
KafkaTransactionalPublisher 178 아니오
KafkaSecurityConfigurer 173 빈으로만 — 호출처 없음
KafkaHeaderMapper 169 예(전송 경유)
KafkaDeliveryMapper 167 아니오
KafkaBatchConsumerRegistrar 146 아니오 — 저장소 전체에 참조 0
PartitionWorkCoordinator 128 아니오
KafkaRetryExecutor 126 아니오
ContiguousPartitionOffsetTracker 119 아니오
KafkaPublishFailureClassifier 116 예(전송 경유)
KafkaPublishMapper 111 예(전송 경유)
KafkaRetryMetadataMapper 93 아니오
KafkaPartitionRetryScheduler 90 아니오
KafkaReplayCapability 82 아니오
KafkaRetryTopicPublisher 75 아니오
SpringKafkaTransactionalProcessor 68 아니오
KafkaOffsetResetExecutor 63 아니오
KafkaProfileValidator 60 예(시작 검증)
KafkaReplayPlanner 58 아니오
KafkaBrokerProfile 56 예(설정 컴파일)
KafkaSettlementQueue · KafkaTransactionProfileValidator 52 · 52 아니오 / 빈만(§17.2)
KafkaSettlementCommand 50 아니오
KafkaDeadLetterPublisher 49 아니오
PartitionOffsetTracker 48 아니오
KafkaTopologyInspector 45 아니오
KafkaRetryOutcome 40 아니오
KafkaPosition 38 예(발행 결과)
KafkaReplayPlan 36 아니오
KafkaQuarantinePublisher 31 기본 구현만
KafkaTransactionalProcessor 29 아니오
KafkaTransactionalDelivery · KafkaTransactionalOutput 22 · 21 아니오

main 총 34파일 / 3,427줄.

Coverage ledger
scope count disposition reason
main/java/** 34 FULL_READ 3,427줄. 위 표가 전부
test/java/** 24 FULL_READ 4,087줄. 인증 레인·Toxiproxy 레인 포함
build.gradle 1 FULL_READ 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일 — 생성물

UNCLASSIFIED 0.

이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판은 main/java/**29 로 적었다. 실제는 34 이고, 빠져 있던 다섯 안에 §17.4 의 KafkaRetryMetadataMapper 가 있었다. "조립되나" 열도 이번에 추가했다 — 이 리프의 판정 등급이 전부 그 열에 달려 있다.


1. 소비자 런타임 — 스레드 규율이 설계다

"Every call into Consumer — poll, pause, resume, seek, commit — happens on the poll thread and nowhere else. KafkaConsumer is documented as not thread-safe, and a worker that committed directly would corrupt the client's internal state under concurrency in ways that surface much later as skipped offsets. Workers therefore enqueue a KafkaSettlementCommand and the poll thread applies it at the top of the next cycle."

공개 API 인 pause/resume 도 제어 큐를 통해 폴 스레드로 넘어가고, 반환된 단계는 다음 폴 주기 에 완료된다.

"so a caller that awaits it knows the consumer is paused rather than merely asked to pause… there is no safe way to touch the consumer from another thread, so 'paused' cannot be true until the loop says so."

close() 만 예외이고 그 예외에 근거가 붙어 있다 — 이후 폴 루프가 멈추므로 큐에 넣으면 영원히 배수되지 않는다.

이 규율은 실제로 지켜진다. 작업자 람다가 만지는 것은 settlements·coordinator·shutdown·retries 뿐이고 consumer 는 한 번도 없다. 통독으로 확인했다.

2. 커밋은 연속 워터마크로만 전진한다

"A Kafka offset commit is a watermark, not a set: committing offset 13 declares that everything below it is done. With concurrent handlers, offsets finish out of order — 10 and 12 may complete while 11 is still running — and committing 13 at that moment would silently discard 11."

그 대가도 적혀 있다 — 느린 메시지 하나가 그 파티션의 워터마크를 붙든다. 그것이 옳은 교환이라는 근거는 대안이 메시지를 잃는다는 것이고, 지연은 소비자 랙으로 보인다는 것이다.

그리고 등록만 되고 제출되지 않은 오프셋을 되돌리는 경로가 있다.

"A delivered offset with no worker behind it holds the contiguous watermark back forever: nothing will ever complete it, so the partition stops committing while continuing to consume."

3. 이미 고쳐진 결함 네 개가 코드에 주석으로 남아 있다

이 리프의 서술 방식이다 — 고친 자리마다 이전 상태를 적어 둔다.

커밋 순서. 지역 맵과 트래커를 commitSync 뒤에 갱신한다. 이전 순서는 실패한 커밋 뒤에 브로커가 받은 적 없는 오프셋을 커밋된 것으로 믿게 했고, 잘린 트래커가 그것을 다시 만들 수 없어 다음 커밋이 간극을 건너뛰었다.

재조정 에폭. 파티션 회수 시 에폭을 먼저 지운다.

"Any settlement still in flight for these partitions now carries a number no live assignment has, so applySettlements refuses it instead of moving a watermark this consumer no longer owns."

전역 break 제거. 한 파티션이 천장에 닿았을 때 배치 전체를 버리던 형태를 파티션별 처리로 바꿨다.

"which abandoned every record the same poll had returned for other partitions… a processing gap that nothing reported."

정착의 단일 종결. acknowledge/requeue/discard 가 하나의 CAS 를 두고 경쟁한다 — 핸들러가 둘 다 말하면 폴 스레드가 두 번째를 믿던 형태를 막는다.

거부된 정착 수는 조용히 세지 않고 staleSettlements() 로 노출한다. 0 이 아니면 핸들러가 자기 할당보다 오래 살고 있다는 뜻이고, 운영자가 행동할 수 있는 신호다.

4. 배압은 버퍼가 아니라 일시정지로 준다

"A partition at its in-flight ceiling stops being fetched, so unprocessed records stay in the broker instead of in the heap."

파티션 단위로만 멈추고, 재개 지점은 그 파티션의 가장 이른 미제출 오프셋이다. 재개 자체가 없는 것이 §17.3 이다.

5. 발행 실패 분류

"The split is between failures that prove the record was not stored and failures that prove nothing… The default is deliberately ambiguous rather than rejected. Guessing 'rejected' on an unknown error is what turns one lost confirmation into two orders."

일곱 예외 타입만 단정적 거부이고, 그중 셋(AuthenticationException·AuthorizationException·SerializationException)은 각각 전용 범주로 간다.

6. 트랜잭션 조건

KafkaTransactionProfileValidator 가 넷을 요구한다 — 트랜잭션 식별자 접두, 멱등 생산자, acks=all, 수동 오프셋 커밋. 그리고 다섯째가 핵심이다.

"A destination that declares INBOX_TRANSACTIONAL is telling the platform its side effect lives in a database, and a Kafka transaction cannot span that. Allowing both to be configured together would let a team read 'transactional' twice and conclude the whole path is atomic when the two halves can still diverge."

10. 테스트 레인

24파일 4,087줄. 세 층이다.

파일 무엇을 붙드나
결정적 KafkaConsumerRegistrarTest(440), KafkaTransactionOrderingTest(192), KafkaProfileValidatorTest(167), KafkaEnvelopeRoundTripTest(154), ReservedHeaderForgeryTest(123), KafkaHeaderMapperTest(118), ContiguousPartitionOffsetTrackerTest(94), KafkaReplayPlannerTest(88), PartitionWorkCoordinatorTest(77), KafkaProducerContractTest(24) MockConsumer·MockProducer 로 폴 주기·트랜잭션 호출 순서·헤더 왕복·워터마크 산술
실브로커 IT KafkaBrokerIT(292), KafkaConsumerSettlementIT(218), KafkaAmbiguityChaosIT(173), KafkaTransactionIT(168), KafkaReadCommittedIT(155), KafkaTransactionFencingIT(150), KafkaTopologyValidationIT(144), KafkaContainerSmokeTest(66) Testcontainers apache/kafka:4.1.0
인증 레인 KafkaBrokerCertificationIT(466) Toxiproxy 로 소켓 단위 결함 주입

인증 레인의 판단이 이 가족에서 가장 강하다.

"No @EnabledIf on Docker, deliberately… a certification lane that skips reports success for a broker nobody started, which is the exact failure the evidence exists to rule out."

그리고 커버하지 못하는 시나리오를 숨기지 않는다 — connection-refused 는 Kafka 생산자가 연결 성립 전에 레코드를 버퍼링하므로 전송에 대해 아무것도 증명하지 못하는 배달 마감으로만 나타난다. 그래서 그것을 knownGaps 로 남긴다.

KafkaReadCommittedIT.abortATransactionCarrying 의 주석도 같은 종류다 — flush() 가 없으면 abort 가 클라이언트 측에서 레코드를 버리므로 빈 토픽에 대해 시험이 무의미하게 통과한다.

12. negative-space probes

12.1 도달성 — 이 리프의 절반이 조립되지 않는다. 스타터는 이렇게 만든다.

return new dev.caskeleton.messaging.kafka.KafkaMessagingTransport("kafka", 1L, producer);

인자 셋짜리 생성자다. 그 생성자는 소비자 팩토리를 이렇게 채운다.

spec -> { throw new MessagingCapabilityUnavailableException(
    "KAFKA_CONSUMER_NOT_CONFIGURED", "this Kafka transport was created without a consumer factory"); }

그리고 저장소 전체에서 new KafkaConsumerRegistrar테스트 5곳에만 있다. 소비 경로 전체 — 폴 루프(621), 정착 큐, 오프셋 트래커, 재시도 스케줄러, 파티션 조정자, 배달 매퍼, 재시도 실행기 — 가 배포에 조립되지 않는다.

조립되는 것은 발행 경로다. 전송·발행 매퍼·헤더 매퍼·실패 분류기·KafkaPosition, 그리고 시작 검증기 하나.

이 사실이 §17.3·§17.4·§17.5 의 등급을 한 칸 낮춘다. 오늘의 사고가 아니라 소비를 배선하는 날의 사고다.

12.2 참조가 0인 production 파일. KafkaBatchConsumerRegistrar 146줄은 저장소 전체에서 자기 파일 밖의 참조가 없다 — production 도 테스트도 아니다. 배치 소비의 규칙(파티션을 넘지 않는 배치, DESTINATION 순서와의 비양립)을 정확하게 서술하고 아무도 부르지 않는다.

12.3 대조군 — 능력 선언 방식. 세 어댑터가 모두 능력을 상수로 둔다. Pulsar 는 전송과 검증기가 서로 다른 값을 답하고, NATS 는 프로파일과 무관하게 중복 제거를 참으로 둔다. Kafka 는 §17.2 의 형태다.

12.4 테스트가 볼 수 없는 것. 소비 경로의 세 결함이 전부 같은 이유로 시험에서 벗어난다.

결함 가리는 형태
§17.3 천장 일시정지 후 재개 없음 결정적 시험은 천장에 닿은 그 주기까지만 단언한다(anOrderedDestinationDispatchesOneRecordAtATime). 실브로커 IT 는 전부 핸들러 풀이 Runnable::run(인라인)이라 천장에 닿지 않고, 전부 레코드 1건만 발행한다
§17.4 재시도 헤더 오염 ReservedHeaderForgeryTest.aMalformedRetryAttemptIsQuarantinedattemptOf던짐만 단언한다. 소비자가 그 던짐을 어떻게 다루는지는 어떤 시험도 보지 않는다
§17.5 벽시계 pollOnce(Instant) 는 시계를 주입받는데 재시도 등록만 Instant.now() 를 읽는다. 지연 재개를 결정적으로 시험할 수 없다

12.5 고쳐진 메서드와 증명된 메서드가 다르다. §17.6.

16. 확인하지 못한 것

  • 실제 브로커로 재조정 중 정착 거부를 재현하지 않았다. 인증 레인이 그 자리이고 컨테이너가 필요하다.
  • §17.3 을 실행으로 재현하지 않았다. consumer.resume(...) 호출처가 둘(applyDueResumes·공개 resume(scope))뿐이고 천장 경로가 retries 에 아무것도 등록하지 않는다는 것으로 판정했다.
  • §17.4 를 실행으로 재현하지 않았다. attemptOfdispatch 의 두 번째 try 안에 있고 그 catchrequeueAfterFailure() 라는 것, MessagingConfigurationExceptionRuntimeException 을 상속한다는 것으로 판정했다.
  • Toxiproxy 인증 레인을 직접 돌리지 않았다. 코드와 그 레인이 기록하는 증거 형식만 읽었다.
  • gradle.lockfile 은 읽지 않았다(STRUCTURAL_ONLY).

17. 손볼 것

17.1 P1 — 지원 문서가 deduplicatedPublish 를 지원으로 적고, 코드는 거짓이며, 그 차이가 정확히 코드가 경고한 피해다
private static final MessagingCapabilities CAPABILITIES =
    new MessagingCapabilities(true, true, true, true, true, true, true, false, true, false, false, true);
//                                                                                      ^^^^^ deduplicatedPublish

코드의 판정이 옳고 그 근거가 javadoc 에 있다.

"Producer idempotence deduplicates sequence retries within one producer session: the producer id is reassigned on restart, so the same logical message published again after a crash is a new sequence and the broker stores it twice."

docs/messaging/support-matrix.md:55 의 능력 표는 이 칸을 O 로 적는다.

그 차이가 무거운 이유는 이 플랫폼에서 이 플래그가 특별하기 때문이다. 능력 열둘 중 부재가 예외를 만드는 유일한 플래그다.

// DefaultMessagePublisher:249-252
if (options.deduplication().isPresent()
    && !transport.capabilities(profile.name()).capabilities().deduplicatedPublish()) {
  throw new ("PUBLISH_DEDUPLICATION_UNSUPPORTED", );

그래서 표를 읽고 중복 제거를 전제한 목적지를 설계한 팀은 실행 시점에 능력 예외를 만난다. 반대로 표를 읽고 "중복 제거가 있으니 모호를 그냥 재시도해도 된다" 고 결론지으면, 실제로는 중복이 저장된다.

MessagingCapabilities 의 클래스 javadoc 이 그 피해를 미리 적는다 — "a silently weakened guarantee is indistinguishable from a working one until the incident."

수정은 문서 쪽이다. 코드가 이미 옳다.

17.2 P2 — 브로커 트랜잭션을 무조건 참으로 선언하고, 그 조건을 검사하는 검증기는 시작 시 돌지 않는다

능력 상수의 아홉 번째가 brokerTransaction = true 다. 프로파일과 무관한 상수다.

그런데 Kafka 트랜잭션이 실제로 성립하려면 KafkaTransactionProfileValidator 가 요구하는 넷이 모두 참이어야 한다 — 트랜잭션 식별자 접두, 멱등 생산자, acks=all, 수동 커밋.

그 검증기는 스타터가 빈으로 만들지만 StartupProfileValidation 으로 감싸지 않는다.

// KafkaMessagingAutoConfiguration
@Bean public KafkaProfileValidator kafkaProfileValidator() {  }
@Bean public StartupProfileValidation<KafkaBrokerProfile> kafkaProfileStartupValidation() {  }  // ← 감싼다
@Bean public KafkaTransactionProfileValidator kafkaTransactionProfileValidator() {  }            // ← 감싸지 않는다

즉 두 겹이 함께 비어 있다. 능력은 조건과 무관하게 참을 답하고, 조건을 검사할 검증기는 발행되기만 하고 주입되지 않는다.

StartupProfileValidation 의 javadoc 이 서술한 이전 결함이 정확히 그 형태다 — "the context published a validator per broker and validated nothing."

수정은 두 갈래를 함께 한다.

  • 스타터에서 kafkaProfileStartupValidation 형태를 복사해 트랜잭션 검증기를 감싼다(스타터 SSOT §17.2 와 같은 수정).
  • 능력을 프로파일에서 파생시킨다 — enableIdempotence && "all".equals(acks) && 접두 존재.

두 번째가 없으면 검증기가 돌더라도 능력 조회는 여전히 프로파일과 무관하게 답한다.

17.3 P2 — 천장에 닿아 일시정지된 파티션을 재개하는 경로가 없다

pollOnce 의 파티션 루프는 세 경우에 그 파티션을 멈춘다.

if (!coordinator.tryAcquire(partition))  { seekBackTo = record.offset(); continue; }   // 천장
if (!shutdown.tryBeginWork())            {                              continue; }   // 배수 시작
if (!dispatch(record, partition, now, epoch)) {                         continue; }   // 풀 거부

if (seekBackTo >= 0) {
  consumer.pause(Set.of(partition));
  consumer.seek(partition, seekBackTo);
}

이 세 경로 중 어느 것도 retries.pauseUntil(...) 을 부르지 않는다. 그런데 폴 루프가 파티션을 재개하는 곳은 하나뿐이다.

private void applyDueResumes(Instant now) {
  Map<TopicPartition, Long> due = retries.dueForResume(now);   // ← retries 에 등록된 것만
  due.forEach((partition, seekTo) -> { consumer.seek(...); coordinator.resume(...); consumer.resume(...); });
}

retries 에 항목을 넣는 곳은 QueuedSettlement.enqueueRequeue 하나이고, 그것은 핸들러 실패·타임아웃·명시적 requeue 경로다. 천장·배수·풀 거부 경로는 등록하지 않는다.

따라서 천장 때문에 멈춘 파티션은 폴 루프가 스스로 재개하지 않는다. 재개할 수 있는 것은 외부에서 부른 resume(scope) 이나 재조정뿐이다.

도달 조건이 좁지 않다. maxInFlightPerOrderingUnit 의 기본값은 1 이다(DestinationSettings.Consumer). 한 폴이 같은 파티션의 레코드를 둘 이상 돌려주는 순간 두 번째에서 tryAcquire 가 거짓이 되고, 그 파티션이 멈춘다. 그 뒤 작업자가 끝나 coordinator.release 로 슬롯이 비어도 consumer 는 여전히 일시정지 상태다.

대조. 같은 파일이 coordinator.pause(...)consumer.pause(...) 를 구분해서 쓴다 — applySettlementsPAUSE_AND_SEEK 는 둘 다 부르고, 천장 경로는 consumer 쪽만 부른다. 그래서 조정자는 그 파티션을 멈춘 것으로 알지 못하고, 결과적으로 tryAcquire 는 계속 참을 답하는데 브로커에서 레코드가 오지 않는다.

수정. 천장 경로가 retries.pauseUntil(partition, seekBackTo, Duration.ZERO, now) 를 등록하면 다음 주기의 applyDueResumes 가 즉시 재개한다. 지연이 0 이므로 dueForResume 이 곧바로 돌려준다. 배수 경로는 재개하지 않는 것이 맞고, 풀 거부 경로는 천장과 같다.

등급. 소비 경로가 조립되지 않으므로(§12.1) P2. 배선하는 순간 P1 이다 — 파티션이 조용히 멈추고, 커밋 워터마크도 함께 멈추므로 소비자 랙만 늘어난다.

17.4 P2 — 오염된 재시도 헤더가 격리되지 않고 무한 pause-and-seek 을 만든다

KafkaRetryMetadataMapper.attemptOf 는 읽을 수 없는 msg.retry.attempt 에 대해 fail-closed 를 택하고, 그 이유를 정확하게 적는다.

} catch (NumberFormatException malformed) {
  // Returning 1 for an unreadable header restarts the retry budget on every redelivery… and the
  // header is caller-influenced, which makes "unreadable" a way to defeat the cap rather than an
  // accident.
  throw new MessagingConfigurationException("RETRY_ATTEMPT_MALFORMED",
      "the retry attempt header is not a positive integer; the message is quarantined rather"
          + " than restarting its retry budget");
}

메시지가 "quarantined" 라고 말한다. 소비자는 그렇게 하지 않는다.

try {
  envelope = deliveryMapper.toEnvelope(record);
} catch (RuntimeException undecodable) {
  if (quarantine.quarantine(record, undecodable)) { settlement.acknowledgeAfterQuarantine(); }
  else { settlement.requeueAfterFailure(); }
   return;                                        // ← 격리 경로는 여기까지다
}
try {
  int attempt = retryMetadataMapper.attemptOf(envelope);     // ← 던지는 자리는 여기다
  
} catch (ExecutionException | RuntimeException failure) {
  settlement.requeueAfterFailure();                          // ← pause-and-seek
}

격리 경로는 디코딩 실패에만 걸려 있다. attemptOf 는 디코딩이 끝난 뒤 두 번째 블록에서 던지고, MessagingConfigurationExceptionMessagingException 을 통해 RuntimeException 이므로 두 번째 catch 가 잡는다. 결과는 requeueAfterFailure()PAUSE_AND_SEEK → 같은 오프셋 재읽기 → 같은 헤더 → 같은 예외다.

즉 fail-closed 가 막으려던 것(재시도 예산 무력화)보다 나쁜 것을 만든다 — 그 파티션이 영구히 그 레코드에서 멈춘다. 그리고 javadoc 이 지적한 대로 이 헤더는 호출자가 쓸 수 있는 값이므로, 숫자가 아닌 값 하나로 파티션 하나를 정지시킬 수 있다.

테스트가 보지 못하는 이유. ReservedHeaderForgeryTest.aMalformedRetryAttemptIsQuarantinedattemptOf 가 던지는 것만 단언한다. 이름은 "quarantined" 인데 격리를 확인하지 않는다.

수정. attemptOf 호출을 디코딩과 같은 블록으로 옮겨 격리 경로에 태우거나, 두 번째 catch 가 예외 종류를 나누게 한다 — MessagingConfigurationException 은 재시도로 회복되지 않는 종류이므로 격리 대상이고, 핸들러 실패는 재시도 대상이다.

17.5 P3 — 시계를 주입받는 클래스가 한 곳에서만 벽시계를 읽는다

KafkaConsumerRegistrar 의 설계 성질이 javadoc 에 적혀 있다.

"pollOnce(Instant) is one full cycle and is public so the whole loop — commit ordering, pause, seek, rebalance — is testable against MockConsumer without threads or sleeps."

주기마다 Instant now 를 받아 applyDueResumes(now) 로 넘긴다. 그런데 그 짝인 등록 쪽은 이렇다.

private SettlementResult enqueueRequeue(Duration delay) {
  retries.pauseUntil(partition, offset, delay, Instant.now());   // ← 주입된 시계가 아니다

이 리프에서 Instant.now() 를 읽는 유일한 자리다. 그리고 그 호출은 작업자 스레드에서 일어나므로 폴 스레드의 now 와 다른 순간이다.

결과는 두 가지다. 지연 재시도(requeue(Duration))의 재개 시점을 고정 시계로 시험할 수 없고, 시험이 Duration.ZERO 밖의 지연을 다루지 못한다 — 실제로 어떤 시험도 다루지 않는다.

수정은 생성자에 Supplier<Instant> 를 하나 더 받는 것이다. 같은 저장소의 MessagingShutdownLifecycle 이 정확히 그 형태로 두 생성자를 둔다.

17.6 P3 — 결함으로 판정된 메서드가 남아 있고, 실브로커 증명이 그것 위에서 돈다

KafkaTransactionalPublisher 에 같은 일을 하는 메서드가 둘 있다.

public <T> T inTransaction(delivery, profiles, inputOffsets, Supplier<T> body)   // begin → body → send → commit
public void sendInTransaction(delivery, profiles, inputOffsets)                   // begin → send → commit  (body 없음)

inTransaction 의 javadoc 이 둘째를 결함으로 지목한다.

"The processor used to run the handler and only afterwards hand the delivery here — so beginTransaction happened after the handler had already finished… a handler that succeeded and a commit that then failed left the handler's work applied with its input offsets unsent."

sendInTransaction 은 public 이고 production 호출자가 없다. 호출하는 것은 시험 다섯 자리뿐이다 — 그리고 그 다섯이 실브로커 트랜잭션 증명 전부다(KafkaTransactionIT·KafkaTransactionFencingIT·KafkaReadCommittedIT).

고쳐진 inTransaction 을 시험하는 것은 KafkaTransactionOrderingTest 하나이고 MockProducer 다. 즉 실브로커에서 커밋·중단·펜싱이 증명된 것은 옛 모양이고, 새 모양은 목 위에서만 증명됐다.

기능적 차이는 크지 않다(body 가 비어 있으면 두 메서드는 같은 호출열을 만든다). 그래도 두 가지가 남는다 — 결함으로 판정된 순서를 만드는 public 진입점이 여전히 열려 있다는 것, 그리고 실브로커 증거가 production 경로가 아닌 것 위에 있다는 것.

수정은 ITs 를 inTransaction(..., () -> null) 로 옮기고 sendInTransaction 을 지우는 것이다.

확인된 설계(문제 아님)
  • 모든 소비자 호출을 폴 스레드로 모으고, 그 이유를 클라이언트의 문서화된 비스레드안전성에서 끌어온 것. 통독으로 실제 준수를 확인했다.
  • 공개 제어 API 도 큐를 지나게 하고, 반환 단계가 다음 주기에 완료된다는 것을 정직하게 서술한 것.
  • close() 만 큐를 지나지 않게 하고 그 예외에 근거를 붙인 것.
  • 연속 워터마크로만 커밋하고, 그 대가를 소비자 랙으로 받아들인 것.
  • 제출되지 않은 배달 등록을 되돌리는 경로.
  • 커밋 뒤에 지역 상태를 갱신하도록 순서를 고치고, 이전 순서가 만든 결함을 주석에 남긴 것.
  • 재조정에서 에폭을 먼저 지워 늦은 정착을 거부하는 것, 그리고 할당에서도 에폭을 파생시켜 수동 할당을 덮은 것.
  • 거부된 정착 수를 지표로 노출한 것.
  • 정착을 단일 종결(CAS)로 만든 것.
  • 파티션별 처리로 바꿔 전역 break 이 만들던 처리 간극을 없앤 것.
  • 알 수 없는 발행 실패의 기본값을 모호로 둔 것.
  • 격리 기본 구현이 false 를 답해 커밋을 막는 것 — 쓸 곳이 없으면 오프셋을 넘기지 않는다.
  • Kafka 트랜잭션이 데이터베이스 부수효과를 덮지 못한다는 것을 검증기가 거부로 표현한 것.
  • 재생 기본값을 격리된 임시 그룹으로 두고, 운영 그룹 재생에 승인을 요구한 것.
  • 오프셋 재설정의 승인 술어를 생성자 인자로 둔 것 — 승인 출처 없이 조립된 런타임은 물리적으로 재설정할 수 없다.
  • JAAS 값 이스케이프 순서(역슬래시 먼저)와 제어문자 거부.
  • OAuth 를 절반만 설정하는 대신 거부한 것.
  • 예약 헤더 위조 거부를 "envelope 필드를 재진술하는 이름" 으로만 좁힌 것 — 재시도·사후처리 재발행이 그 가드에 걸리지 않는다.
  • 인증 레인이 Docker 조건부 skip 을 쓰지 않는 것, 그리고 커버 못 하는 시나리오를 knownGaps 로 남긴 것.
  • pollOnce 를 공개해 전체 주기를 스레드 없이 검증 가능하게 만든 것.

Source anchors

src/messaging/messaging-kafka/build.gradle
main/java/…/kafka/KafkaConsumerRegistrar.java:1-621   (§17.3 pollOnce:216-257 · dispatch:264-333 · applyDueResumes:375-383)
main/java/…/kafka/KafkaMessagingTransport.java:1-215  (능력 상수 62-64)
main/java/…/kafka/KafkaTransactionalPublisher.java:1-178  (§17.6 inTransaction:96-119 · sendInTransaction:144-178)
main/java/…/kafka/KafkaSecurityConfigurer.java:1-173
main/java/…/kafka/KafkaHeaderMapper.java:1-169
main/java/…/kafka/KafkaDeliveryMapper.java:1-167
main/java/…/kafka/KafkaBatchConsumerRegistrar.java:1-146  (§12.2 참조 0)
main/java/…/kafka/PartitionWorkCoordinator.java:1-128
main/java/…/kafka/KafkaRetryExecutor.java:1-126
main/java/…/kafka/ContiguousPartitionOffsetTracker.java:1-119
main/java/…/kafka/KafkaPublishFailureClassifier.java:1-116
main/java/…/kafka/KafkaPublishMapper.java:1-111
main/java/…/kafka/KafkaRetryMetadataMapper.java:1-93   (§17.4 attemptOf:725-748)
main/java/…/kafka/KafkaPartitionRetryScheduler.java:1-90
main/java/…/kafka/KafkaReplayCapability.java:1-82
main/java/…/kafka/KafkaRetryTopicPublisher.java:1-75
main/java/…/kafka/SpringKafkaTransactionalProcessor.java:1-68
main/java/…/kafka/KafkaOffsetResetExecutor.java:1-63
main/java/…/kafka/KafkaProfileValidator.java:1-60
main/java/…/kafka/KafkaReplayPlanner.java:1-58
main/java/…/kafka/KafkaBrokerProfile.java:1-56
main/java/…/kafka/{KafkaSettlementQueue:1-52, KafkaTransactionProfileValidator:1-52, KafkaSettlementCommand:1-50,
                   KafkaDeadLetterPublisher:1-49, PartitionOffsetTracker:1-48, KafkaTopologyInspector:1-45,
                   KafkaRetryOutcome:1-40, KafkaPosition:1-38, KafkaReplayPlan:1-36, KafkaQuarantinePublisher:1-31,
                   KafkaTransactionalProcessor:1-29, KafkaTransactionalDelivery:1-22, KafkaTransactionalOutput:1-21}
test/java/…/kafka/ 24파일 4,087줄 (KafkaBrokerCertificationIT:466 · KafkaConsumerRegistrarTest:440 · KafkaContractHarness:370 …)
messaging-spring-boot-starter/…/KafkaMessagingAutoConfiguration.java:100-125 (§12.1 발행 전용 조립 · §17.2)
messaging-runtime-core/…/DefaultMessagePublisher.java:249-252 (§17.1 능력 부재가 예외를 만드는 유일한 자리)
docs/messaging/support-matrix.md:55 (§17.1 능력 표 대조)

A19-MESSAGING-NATS-EXPERIMENTAL. messaging-nats-experimental

분석 중에는 messaging/MESSAGING-NATS-EXPERIMENTAL.md 파일이었다. 291줄.

messaging-nats-experimental 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 7파일 755줄 축자 통독 완료. test 2파일 460줄 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-nats-experimental SSOT owner: messaging-nats-experimental integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • 선언 의존: messaging 계열 project 7 + vendor jnats:2.26.2
  • runtime_memberships: [] — build-only · 등급 EXPERIMENTAL
파일 LOC
NatsJetStreamTransport 295
NatsJetStreamProfile 103
NatsMaxDeliverParkingWorkflow 85
NatsJetStreamProfileValidator · NatsStreamPosition 75 · 75
NatsPreSendRejection 65
NatsAckMode 57
main 합계 755
NatsAdapterContractTest · NatsMaxDeliverParkingTest 337 · 123
Coverage ledger
scope count disposition reason
main/java/** 7 FULL_READ 755줄 전 본문
test/java/** 2 FULL_READ 460줄 전 본문 · 테스트 28개
build.gradle 1 FULL_READ 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 이 어댑터의 판단 셋

JetStream 만 쓴다.

"A core publish returns as soon as the bytes are written to the socket, with no persistence and no acknowledgement, so an adapter using it would report success for messages that were never stored — the failure is total and silent."

거부 코드는 NatsJetStreamProfileValidator.validate 에 있다 — 최소 한 번 배달 목적지에 코어 NATS 는 안 된다. 다만 그 검증기를 호출하는 곳이 저장소에 하나도 없다(§17.3). 이 절이 서술하는 것은 판단이 코드로 적혀 있다는 사실이지, 그 판단이 실행 경로에 걸려 있다는 사실이 아니다.

확인은 지속 증거다. 발행 승인이 메시지가 안착한 스트림과 순번을 이름 짓는다. 소켓에 바이트를 쓴 영수증이 아니다.

기본 실패는 모호다. 사전 거절 타입만 확실히 전송되지 않음으로 다루고 나머지는 전부 모호다.

"a caller that reads REJECTED may republish under a new identity and duplicate a message the server already stored."

2. 죽은 편지가 없는 브로커에서 죽은 편지를 만든다

NatsMaxDeliverParkingWorkflow javadoc:

"JetStream has no dead-letter queue. When a message hits maxDeliver the server terminates it: no redelivery, no routing, no record beyond an advisory. Every other broker in this platform parks a poison message somewhere an operator can find it, and this workflow is what makes NATS behave the same way."

핵심은 시점이다.

"The parking therefore happens on the delivery before the limit, not on the limit itself. Acting at maxDeliver would mean acting on the delivery JetStream is about to discard, so any failure in the dead-letter publish would lose the message outright."

그래서 프로파일이 maxDeliver < 2 를 거부한다 — 플랫폼이 주차할 여유 배달이 최소 하나 있어야 한다.

그리고 정착은 죽은 편지 발행이 확인된 뒤에만 허용된다.

"Terminating first would discard the message on a broker that cannot redeliver it, which is the one irreversible mistake available here."

세 번째 결과 ALREADY_TERMINATED 는 살아 있는 소비자 아래에서 프로파일이 바뀐 경우에만 도달한다. 회복할 것이 없고, 재배달로 오인되지 않도록 결과로 남긴다.

3. 능력 선언

CAPABILITIES = (true, true, true, true, true, false, true, false, false, true, false, true);

nativeDeadLetter=false 의 근거가 클래스 javadoc 과 검증기 javadoc 양쪽에 있다 — 없는 큐를 찾아 나서게 만들지 않는다.

keyedOrdering=false 도 검증기가 강제한다 — 키 순서를 요구하는 목적지를 거부한다.

deduplicatedPublish=true 는 §17.1 이 다룬다.

4. 프로파일이 스스로 거부하는 것

NatsJetStreamProfile 은 record 이고, 압축 생성자가 이 어댑터의 불변식을 전부 들고 있다. 검증기가 호출되지 않는 지금, 실제로 실행되는 유일한 게이트가 여기다.

if (!ackMode.supportsAtLeastOnce()) throw ;              // NONE · ALL 거부
if (ackWait.isNegative() || ackWait.isZero()) throw ;
if (maxDeliver < 2) throw ;                              // "headroom"
if (deduplicationWindow.isPresent() && isZero()) throw ; // 설정했으면 양수

ackMode 거부 사유는 NatsAckMode 자신이 문장으로 들고 있고(rejectionReason()), 프로파일이 그 문장을 예외 메시지에 그대로 싣는다. NONE 은 "forgotten", ALL 은 "still in flight" — 테스트가 그 두 낱말로 각각 걸어 잠근다.

PARKING_HEADROOM = 1 상수와 parkAtDelivery() = maxDeliver - PARKING_HEADROOM 가 §2 의 시점 선택을 숫자로 못 박는다. NatsMaxDeliverParkingWorkflow.parkingThreshold() 는 이 값을 그대로 위임한다 — 임계값의 정의가 한 곳에만 있다.

주의할 비대칭. 편의 팩토리 durable(subject, stream, durableName) 는 중복 제거 창을 Optional.of(2분) 으로 채운다. 즉 팩토리를 거친 프로파일은 §17.1 의 구멍에 빠지지 않는다. 그러나 팩토리에도 호출자가 없고(저장소 전역 grep 0건), 정규 생성자는 빈 창을 정상값으로 받는다. 기본 경로가 안전하다는 사실이 그 구멍을 닫아 주지 않는다.

10. 테스트 레인

두 테스트 460줄 · 28개.

NatsAdapterContractTest 17개 — 지속 증거(REPLICATION_OR_PERSISTENCE_ACK), 위치 반환, 시간 초과의 모호 판정, 사전 거절만이 NOT_TRANSMITTED 라는 것, 감싸인 미지 실패의 모호 판정, 호출자 마감의 유효성, 중복 제거 식별자 유무, 초과 페이로드 거절, 능력 두 개, 닫힌 전송, 재배달 인식, 순번 하한, 실패 범주.

전송은 (subject, deduplicationId, request) -> CompletionStage<NatsStreamPosition> 람다로 주입된다. 실제 JetStream 클라이언트는 이 리프에 없고, 테스트가 성공·실패·영영 안 끝남을 직접 만든다.

두 테스트가 회귀를 이름으로 기록한다 — aFailureNamedLikeAKnownOneIsStillAmbiguous 는 "예외 클래스 이름이 분류자였던" 과거를, aPublishThatNeverCompletesIsBoundedByTheCallersTimeout 은 "호출자 마감이 아예 무시되던" 과거를 주석에 남긴다. 셋째 회귀 기록은 어셈블이 비어 있다(§17.4).

NatsMaxDeliverParkingTest 11개 — 한계 직전 주차, 한계 자체도 주차, 한계 초과의 ALREADY_TERMINATED, 확인 뒤 정착, maxDeliver=1 거부, 배달 수 하한, 임계값, ackMode 세 값.

NatsJetStreamProfileValidator 를 세우는 테스트는 없다.

12. negative-space probes

12.1 도달성. dev.caskeleton.messaging.nats 를 import 하는 코드가 리프 밖에 없다. 리프 밖에서 이 모듈이 등장하는 곳은 세 군데인데 전부 이름 문자열이다 — config/architecture/modules.json 의 등록, messaging-testkit/CompatibilityMatrix("messaging-nats-experimental", List.of("2.14"), Tier.EXPERIMENTAL, false, false) 항목, 그리고 그 표를 문서와 대조하는 MessagingDocumentationContractTest. 즉 등급표가 이 어댑터를 알고 있을 뿐, 어떤 실행 경로도 이 클래스들에 닿지 않는다. build-only · experimental 표기 그대로다.

12.2 대조군 — 자매 실험 어댑터. messaging-pulsar-experimental 과 구조가 같다 — 주입되는 전송 연산, 타입 있는 사전 거절, 기본 모호, 실험 등급 게이트. 차이는 능력 선언의 출처다. Pulsar 는 전송과 검증기가 서로 다른 값을 답하고(그쪽 §17.1), NATS 는 두 곳이 같은 값을 답한다.

다만 그 일치는 공유가 아니라 복사다. NatsJetStreamTransport.CAPABILITIES 상수와 NatsJetStreamProfileValidator.capabilities() 가 열두 개 불리언 리터럴을 각자 손으로 적어 두었고, 둘을 묶는 것은 아무것도 없다. 오늘 같은 값인 것이 내일도 같으리라는 보장은 코드에 없다 — Pulsar 가 이미 그 갈라짐의 실물이다.

이쪽의 문제는 따로 있다. 그 값이 프로파일에서 파생되지 않는다는 것이다(§17.1).

12.4 드리프트. 실험 등급 표기와 코드가 일치한다.

16. 확인하지 못한 것

  • 실제 JetStream 서버를 띄우지 않았다. 클라이언트 브리지를 싣지 않는 리프다.
  • 중복 제거 창이 없는 프로파일로 모호 재발행을 재현하지 않았다. 능력 상수와 deduplicationId 구현으로 판정했다.
  • 검증기를 부르는 조립 지점이 다른 형태(설정 클래스 · 스타터)로 어딘가에 있을 가능성은 클래스 이름 · 패키지 이름 두 가지 grep 으로만 배제했다. 리플렉션이나 문자열 기반 조립이라면 잡히지 않는다.
  • 테스트를 실행하지 않았다. §17.4 의 "항상 통과"는 어셈블 의미론으로 판정한 것이다.

17. 손볼 것

17.1 P2 — deduplicatedPublish 를 무조건 참으로 선언하는데 실제 중복 제거는 프로파일에 창이 있을 때만 일어난다

능력은 상수다.

private static final MessagingCapabilities CAPABILITIES =
    new MessagingCapabilities(true, true, true, true, true, false, true, false, false, true, false, true);
//                                                                             ^^^^ deduplicatedPublish

검증기의 capabilities() 도 같은 값을 돌려준다.

그런데 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어진다.

private Optional<String> deduplicationId(TransportPublishRequest request) {
  return profile.deduplicationWindow().map(window -> request.envelope().messageId().value().toString());
}

NatsJetStreamProfile.deduplicationWindowOptional<Duration> 이고, 비어 있는 것이 정상 상태다 — 프로파일 생성자도 검증기도 창을 요구하지 않는다. 창이 없으면 Nats-Msg-Id 가 실리지 않고 서버는 중복을 제거하지 않는다.

즉 능력 선언이 프로파일과 무관하게 참이다.

왜 이 플래그인가. 이 저장소에서 능력 열두 개 중 부재가 예외를 만드는 유일한 것이 deduplicatedPublish 다(DefaultMessagePublisher:250). 나머지는 읽히지 않거나 분기에 쓰인다. 그러므로 이 플래그의 과대 선언은 다른 어느 플래그의 과대 선언보다 직접적이다 — 창 없는 목적지가 그 가드를 통과한다.

그리고 어댑터 자신이 그 조건을 알고 있다. 클래스 javadoc:

"A publish that times out is AMBIGUOUS: JetStream may have stored it and lost only the acknowledgement, and the deduplication window is what makes retrying it safe when the profile enables one."

"when the profile enables one" 이 정확히 능력이 담지 않은 조건이다. 창이 없는 목적지에서 모호를 재시도하면 스트림에 같은 메시지가 두 번 들어간다.

MessagingCapabilities 의 클래스 javadoc 이 이 상황을 미리 서술한다 — "a silently weakened guarantee is indistinguishable from a working one until the incident."

테스트가 두 쪽을 동시에 못 박는다. NatsAdapterContractTest 안에서, 같은 빈 창 프로파일(confirming(Optional.empty()))에 대해:

void theAdapterAdvertisesDeduplicatedPublish() {
  assertThat(confirming(Optional.empty()).capabilities().capabilities()
      .deduplicatedPublish()).isTrue();          // 능력은 참이라고 한다
}

void noDeduplicationWindowSendsNoDeduplicationId() {
  confirming(Optional.empty()).publish(request(64));
  assertThat(capturedDeduplicationIds).singleElement()
      .satisfies(id -> assertThat(id).isEmpty()); // 선에는 아무것도 안 실린다
}

둘 다 통과한다. 모순이 우연히 남은 것이 아니라 테스트로 고정되어 있다는 뜻이고, 수정할 때 함께 고쳐야 할 지점이 어디인지도 이 두 개가 알려 준다.

팩토리는 이 구멍을 메우지 않는다. NatsJetStreamProfile.durable(...) 는 창을 2분으로 채워 주지만 호출자가 없고, 정규 생성자는 빈 창을 정상값으로 받는다(§4).

수정. 능력을 프로파일에서 파생시킨다.

new MessagingCapabilities(, profile.deduplicationWindow().isPresent(), )

또는 검증기가 최소 한 번 배달 목적지에 중복 제거 창을 요구한다. 후자는 코어 NATS 거부와 같은 형태의 시작 시점 거부다.

17.2 P3 — 닫힌 전송의 거절이 영구 업무 실패로 분류된다

rejectedLocallyFailureCategory.PERMANENT_BUSINESS 를 고정으로 쓰고, 두 호출자 중 하나가 NATS_TRANSPORT_CLOSED 다.

자매 어댑터(Pulsar)와 같은 형태이고 같은 판단이다 — 종료 중이라는 것은 이 세대의 사정이지 업무의 영구 실패가 아니다. 같은 파일의 classify 는 범주를 신중히 나눈다.

두 리프가 같은 형태를 공유하므로 수정도 함께 하는 편이 낫다.

17.3 P2 — NatsJetStreamProfileValidator 를 호출하는 곳이 저장소에 없다. javadoc 링크 하나가 유일한 흔적이다

75줄짜리 검증기가 이 어댑터의 시작 시점 판단 넷을 들고 있다 — 실험 스위치가 꺼져 있으면 거부, 최소 한 번 배달에 코어 NATS 거부, 순서 있는 소비자와 경쟁 작업자 동시 사용 거부, 키 순서 목적지 거부.

저장소 전역에서 이 클래스 이름이 나오는 곳은 두 줄뿐이다.

NatsJetStreamTransport.java:35:  * is why {@link NatsJetStreamProfileValidator} refuses the combination at startup.
NatsJetStreamProfileValidator.java:21: public final class NatsJetStreamProfileValidator {

하나는 선언이고 하나는 javadoc 링크다. 코드 호출자 0, 테스트 0.

validatejetStreamEnabled · orderedConsumer · competingWorkers · enabled 를 전부 인자로 받는다. 즉 스스로 아무것도 관찰하지 않고, 호출자가 이미 알고 있는 사실을 넘겨 줘야만 판단한다. 그런 호출자가 없으니 이 판단들은 한 번도 실행된 적이 없다.

왜 P2 인가. 전송의 클래스 javadoc 이 "그래서 검증기가 시작 시 그 조합을 거부한다"고 단언한다. 읽는 사람에게 이 어댑터는 코어 NATS 오설정으로부터 보호되는 것처럼 보이는데, 실제로는 아무 게이트도 걸려 있지 않다. 실험 등급이라 지금 당장 사고가 나지는 않지만, 이 어댑터를 실전에 붙이는 사람이 가장 먼저 신뢰할 문장이 지금 사실이 아니다.

같은 형태를 이 저장소에서 여러 번 봤다 — 채점기는 있는데 그 채점기에 값을 넣어 주는 생산자가 없는 구조(GrpcRawApiImportRule · GrpcApplicationBoundaryRules · GrpcNettyParityContract 등). 이쪽이 더 나쁜 쪽인 이유는 그 리프들에서는 최소한 테스트가 리터럴을 먹여 판단 자체는 실행해 보는데, 여기서는 그것조차 없다는 점이다.

수정. 어댑터 조립 지점에서 validate 를 부르거나, 그럴 지점이 아직 없다면 최소한 프로파일 생성 시점에 걸리도록 옮긴다(§4 의 압축 생성자가 이미 실행되는 유일한 게이트다). 어느 쪽도 못 하겠다면 전송 javadoc 의 "refuses ... at startup" 을 사실에 맞게 고친다.

17.4 P3 — 경과 시간 회귀를 막으려는 어셈블이 항상 참이다
@Test
void theReportedElapsedTimeIsMeasuredRatherThanZero() {
  PublishResult result = await(failingWith(new TimeoutException("no ack")).publish(request(64)));

  assertThat(result.elapsed())
      .as("every outcome reported Duration.ZERO, so latency evidence was fiction")
      .isGreaterThanOrEqualTo(Duration.ZERO);
}

as(...) 가 막으려는 회귀는 "모든 결과가 Duration.ZERO 를 보고하던 것"이다. 그런데 어셈블은 >= Duration.ZERO 다. Duration.ZERO 는 이 조건을 통과한다. 경과 시간은 시작 시점에서 잰 값이라 음수가 될 수 없으므로, 이 어셈블은 구현이 무엇을 하든 통과한다.

이름과 as 메시지가 정확히 짚은 회귀를, 어셈블만 못 잡는다. 그래서 이 테스트는 회귀 방지가 아니라 회귀 방지의 표시다.

수정. isGreaterThan(Duration.ZERO) 로 바꾼다. 시간 분해능이 불안하면 전송 람다에 관측 가능한 지연을 넣고 그 하한과 비교한다 — 같은 클래스의 aPublishThatNeverCompletesIsBoundedByTheCallersTimeout 가 이미 50밀리초 마감으로 그 방식을 쓴다.

확인된 설계(문제 아님)
  • 코어 NATS 를 최소 한 번 배달에 쓰지 못하게 시작 시 거부한 것과 그 근거.
  • 확인을 지속 증거로 기록한 것 — 스트림과 순번을 이름 짓는 승인이다.
  • 알 수 없는 실패의 기본값을 모호로 둔 것.
  • 한계 직전 배달에서 주차하는 것과 그 시점 선택의 근거.
  • maxDeliver < 2 를 거부해 주차 여유를 강제한 것.
  • 죽은 편지 발행이 확인된 뒤에만 원본을 정착시키는 것.
  • ALREADY_TERMINATED 를 별도 결과로 남겨 재배달과 구분한 것.
  • nativeDeadLetter=false 를 선언하고 그 이유를 두 곳에 적은 것.
  • 순서 있는 소비자와 경쟁 작업자의 배타성을 검증기가 강제한 것.
  • 중복 제거 식별자로 논리 메시지 식별자를 쓰는 것 — 시도마다 새 식별자를 만들면 창이 필요한 상황에서 쓸모가 없어진다.
  • 예외 클래스 이름으로 실패를 분류하던 것을 걷어내고 타입으로 옮긴 것 — 테스트가 그 회귀를 주석으로 남겨 두었다.
  • 주차 임계값의 정의를 프로파일 한 곳에만 둔 것 — 워크플로는 parkAtDelivery() 를 위임만 한다.
  • NatsStreamPosition 이 스트림 순번과 소비자 순번을 따로 들고 있는 것 — 재배달 인식이 둘의 차이에서 나오고, 재생은 스트림 순번으로만 되돌아간다.

Source anchors

src/messaging/messaging-nats-experimental/build.gradle
main/java/…/nats/NatsJetStreamTransport.java:1-295
main/java/…/nats/NatsJetStreamProfile.java:1-103
main/java/…/nats/NatsMaxDeliverParkingWorkflow.java:1-85
main/java/…/nats/NatsJetStreamProfileValidator.java:1-75
main/java/…/nats/NatsStreamPosition.java:1-75
main/java/…/nats/NatsPreSendRejection.java:1-65
main/java/…/nats/NatsAckMode.java:1-57
test/java/…/nats/NatsAdapterContractTest.java:1-337
test/java/…/nats/NatsMaxDeliverParkingTest.java:1-123
src/messaging/messaging-core-api/…/destination/MessagingCapabilities.java (성분 의미)
src/messaging/messaging-testkit/…/CompatibilityMatrix.java:107-109 (등급표의 이름 항목)
src/config/architecture/modules.json (등록)

A19-MESSAGING-OBSERVABILITY. messaging-observability

분석 중에는 messaging/MESSAGING-OBSERVABILITY.md 파일이었다. 775줄.

messaging-observability 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-observability SSOT owner: messaging-observability integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-observability
  • canonical state analysisFile: §A19-MESSAGING-OBSERVABILITY
  • source path: src/messaging/messaging-observability
  • registry allowed_dependencies: ["messaging-core-api"]
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 9
production LOC 838
패키지 1 (dev.caskeleton.messaging.observation)
test 파일 6
test 메서드(실행 확인) 42
외부(비프로젝트) 의존성 1 (io.micrometer:micrometer-core, api)

아홉 타입을 세 축으로 나누면:

타입 leaf 밖 소비자
관측 seam MessagingObservation(interface) · MessagingMetrics(Micrometer 구현) · MessagingTags(record) · DefaultMessagingObservationConvention seam 2 · 구현 0 · tags 2 · convention 0
경계 CardinalityGuard · MessagingRedactor 1 · 1
추적·감사 MessagingTracer · MessagingAuditSink · MessagingAuditEvent 0 · 0 · 2
Coverage ledger
scope/file group count disposition reason
src/main/java/** (9) 9 FULL_READ 전 파일 본문 확인
src/test/java/** (6) 6 FULL_READ 클래스 javadoc·단언·테스트명 전수 확인
build.gradle 1 FULL_READ 주석 포함 9줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

이 leaf는 **"메시징이 무엇을 밖으로 내보내도 되는가"**를 소유한다. 메트릭·추적·감사 셋이 여기 있고, 셋 다 같은 제약 아래 있다 — 경계가 알려진 값만 나간다.

Micrometer를 api로 선언한 이유가 build.gradle에 있다.

// api: MessagingMetrics' public constructor takes a MeterRegistry, so wiring it requires
// naming the type.
api 'io.micrometer:micrometer-core'

src/messaging/CLAUDE.md:40-43의 vendor api 게이트를 통과한다. 다만 MessagingObservation 인터페이스 자체는 Micrometer를 모른다 — 벤더는 MessagingMetrics 한 클래스에만 나타난다. 즉 seam은 중립이고 구현만 벤더에 묶인다.

의존이 messaging-core-api 하나뿐인 것도 의도적이다. MessagingTracerTraceContext·MessageHeaders를 쓰고 DefaultMessagingObservationConventionPublishCompletion·FailureCategory를 쓴다. policy나 transport는 필요 없다.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), micrometer-core(api).

나가는 것: messaging-runtime-core, messaging-kafka, messaging-rabbit, messaging-admin-runtime, messaging-pulsar-experimental, messaging-nats-experimental, messaging-spring-boot-starter.

출하 조립은 두 개뿐이다.

bean 라인 소비
MessagingRedactor MessagingCoreAutoConfiguration:253 없음
CardinalityGuard :264 없음

두 클래스는 MessagingMetrics의 생성자 인자다. 그런데 MessagingMetrics bean이 없다(§12.1). 즉 재료 둘만 bean으로 있고 그것을 조립하는 것이 없다.

MessagingTracer·MessagingAuditSink·DefaultMessagingObservationConvention은 bean도 없고 소비자도 없다.


3. 패키지/컴포넌트 지도

seam
  MessagingObservation (5 메서드: publish · delivery · settlement · backlog · diagnostics)
      ↑ 구현
  MessagingMetrics ──┬── CardinalityGuard  (차원당 200값 상한)
                     ├── MessagingRedactor (키 denylist 27개)
                     └── MeterRegistry     (Micrometer)

어휘
  MessagingTags (record, 6차원 고정)
      ↑ 생성
  DefaultMessagingObservationConvention (publish/consume/settlement/deadLetter)

추적
  MessagingTracer  (inject / extract / shouldLinkRatherThanContinue)

감사
  MessagingAuditSink (interface + InMemory) ── MessagingAuditEvent (record)

4. 계약·불변식·상태 모델

4.1 MessagingTags — 닫힌 6차원
// :8-14
 * <p>It is a fixed record rather than an open map on purpose. Every field here is bounded by
 * configuration or by an enum, so the cardinality of the metric is known before it is ever scraped.
 * Message ids, partition keys, tenant ids, and offsets are all deliberately absent: each of them is
 * unbounded at runtime and would multiply every series by the message volume.

여섯 차원: broker, destinationProfile, operation, outcome, failureCategory, retryStage. 없는 값은 NONE = "none"이다 — null도 빈 문자열도 아니고 명시적 sentinel이다.

두 factory의 차이가 §12.1의 핵심이 된다.

factory failureCategory retryStage
new MessagingTags(6개 인자) 호출자가 지정 호출자가 지정
MessagingTags.of(4개 인자) NONE 고정 NONE 고정

asMap()LinkedHashMap으로 순서를 고정하고 Map.copyOf로 불변화한다.

4.2 DefaultMessagingObservationConvention — 태그 값이 공개 계약이다
// :12-18
 * <p>Centralised because the tag values are a public contract: dashboards, alert rules, and SLOs
 * are written against these exact strings, so an adapter inventing its own spelling of "rejected"
 * silently breaks every alert that was watching for it. The conversion lives here, once, rather
 * than at each call site.
 *
 * <p>Only bounded inputs are accepted. Every parameter is an enum or a configured name, which is
 * what lets {@link CardinalityGuard} bound the resulting series.

네 메서드와 네 상수(PUBLISH, CONSUME, SETTLE, DEAD_LETTER). publish(...)PublishCompletionOptional<FailureCategory>를 받아 enum에서 문자열을 파생한다 — 호출자가 철자를 정하지 않는다.

이 클래스는 소비자가 0이다(§12.1).

4.3 CardinalityGuard — 실패가 점진적이지 않다
// :9-17
 * <p>Cardinality failures are not gradual. A tag that accidentally carries a message id looks fine
 * in a test with ten messages and takes down the metrics backend in production, and by then the
 * series already exist. The guard bounds each dimension at registration time and refuses the value
 * that would cross the limit, so the damage is one rejected tag rather than a monitoring outage.
 *
 * <p>It fails loudly rather than silently substituting a placeholder, because a metric that quietly
 * collapses distinct values is worse than one that is missing: it looks correct.

기본 상한 200/차원.

두 개의 이전 결함이 코드에 남아 있다.

// admit(String, String):57-59
// Size-then-add was not atomic: N threads could each read size == limit - 1 and each add, so
// the configured limit was an average rather than a bound. A guard that can be exceeded under
// load is no guard — load is when it matters.
synchronized (values) { ... }

먼저 lock 없이 values.contains(value)로 빠른 경로를 두고, 새 값일 때만 synchronized로 들어가 다시 확인한다 — double-checked 패턴이다. 테스트가 경합을 직접 재현한다(MessagingSecretLeakTest.concurrentAdmissionNeverExceedsTheLimit).

// admit(MessagingTags):81-85
// Preflight every dimension before committing any of them.
//
// The loop used to admit each dimension as it went, so a tag set rejected on its last
// dimension had already permanently added the earlier ones — spending the budget of a bounded
// dimension on a series that was never emitted.

wouldAdmit으로 전수 사전 확인 후 admit으로 커밋한다. 사전 확인과 커밋 사이에 lock이 없으므로 두 스레드가 동시에 통과할 수 있고, 그 경우 두 번째 admit이 false를 반환해 admitted &= ...가 false가 된다 — 상한은 지켜지고 결과만 거절이 된다. 안전한 방향이다.

4.4 MessagingRedactor — allowlist가 아니라 denylist인 이유
// :11-15
 * <p>This is a denylist of keys that must never leave the process, not an allowlist, because
 * diagnostic maps are assembled ad hoc at call sites and an allowlist would quietly drop the useful
 * half. Two categories are removed. Secrets, for the obvious reason. And per-message identity 
 * message ids, keys, offsets, delivery tags  because those are what turn a bounded metric into one
 * series per message, and a support log into a re-identification surface.

27개 키. 두 범주를 섞어 담는다.

범주
자격증명 (11) authorization, proxy-authorization, cookie, set-cookie, access_token, refresh_token, api_key, apikey, password, client_secret, credential, secret, token
메시지별 신원 (10) messageid, msg.id, correlationid, causationid, partitionkey, orderingkey, key, offset, deliverytag, sequence
본문·진단 (5) payload, body, data, exceptionmessage, stacktrace

두 메서드가 다른 목적을 갖는다.

메서드 동작 언제
sanitize 거부 키를 제거 값이 나가면 안 되고 키의 존재도 의미 없을 때
mask 값을 [redacted]대체 "Useful where the presence of a field is itself the diagnostic signal"

isDenied가 소문자 정규화 후 정확 일치다. messaging-core-apiMessageHeaders.carriesACredential은 세그먼트 매칭 + 인접 결합(그쪽 §4.6)인데 이쪽은 정확 일치다 — 같은 저장소에서 같은 문제를 두 강도로 푼다(§12.3).

msg.id가 목록에 리터럴로 들어 있다. ReservedHeaders.MESSAGE_ID 상수가 있는데 참조하지 않는다 — §A19-MESSAGING-CORE-API §12.3(c)가 이 사실을 관측했다.

4.5 MessagingMetrics — 순서가 계약이다
// :19-27
 * <p>Every tag set passes the {@link CardinalityGuard} before a meter is created. That ordering is
 * the whole point: a meter registry never forgets a series, so a single tag carrying a message id
 * permanently inflates the backend. Refused tag sets are counted under a fixed {@code
 * messaging.tags.rejected} counter, which makes the rejection visible without creating the series
 * that caused it.
 *
 * <p>Logical messages and physical attempts are separate meters. One message redelivered four times
 * is one publish and five attempts; a single counter would make a redelivery storm read as traffic
 * growth and hide the incident.

여섯 미터:

상수 이름 종류
PUBLISH_TIMER messaging.publish Timer (histogram)
DELIVERY_TIMER messaging.delivery Timer (histogram)
MESSAGE_COUNTER messaging.messages Counter — 첫 시도만
SETTLEMENT_COUNTER messaging.settlements Counter
BACKLOG_GAUGE messaging.backlog Gauge
REJECTED_TAGS_COUNTER messaging.tags.rejected Gauge (LongAdder)
// recordDelivery:87-89
// Only the first attempt counts as a logical message; later attempts are the same
// message arriving again, and counting them would inflate throughput during a storm.
if (attempt == 1) { registry.counter(MESSAGE_COUNTER, micrometerTags).increment(); }

거절 카운터가 gauge인 것이 중요하다. 거절된 태그 세트는 미터를 만들지 않으므로 그 사실을 기록할 유일한 방법이 고정 이름의 별도 미터다. 그것마저 태그를 붙이면 같은 문제가 생긴다.

recordDiagnostics가 가장 긴 주석을 갖는다.

// :122-132
// The value never becomes a tag.
//
// It used to: every diagnostic key and value was attached to a counter, behind a guard that
// only bounded the base dimensions. One unique message id, exception message or URL per
// request created one meter series per request — permanently, in the backend and in this
// process's heap — and the redactor only masks keys it recognises, so free-form text carried
// whatever it carried.
//
// What stays is the shape: which diagnostic keys occurred, counted against the bounded base
// dimensions. The values belong in a structured log or a trace event, where they are bounded
// by retention rather than by cardinality.

현재 구현은 키만 태그로 만들고(Tag.of("diagnostic", key)), 그 키도 guard.admit("diagnostic", key)를 통과해야 한다. 값은 어디에도 가지 않는다.

redaction 순서도 명시돼 있다 — "Redact before anything else touches the values. Diagnostics are the one place where a caller can pass arbitrary keys."

backlogs 맵이 computeIfAbsent로 gauge를 한 번만 등록하고 AtomicLong을 재사용한다 — Micrometer gauge는 재등록해도 첫 참조를 유지하므로 필요한 패턴이다.

4.6 MessagingTracer — 브로커 홉을 건너는 추적
// :12-22
 * <p>Messaging breaks in-process trace propagation: the publish and the consume happen in different
 * processes, often minutes apart, so the only way the two spans meet is if the context travels in
 * the message headers. W3C {@code traceparent}/{@code tracestate} are used rather than a private
 * format so that a non-Java consumer, or a broker-side tool, can still join the trace.
 *
 * <p>The consume side is deliberately a <em>link</em> rather than a child span in the general case.
 * A batch consume can draw messages from many unrelated traces, and forcing them into one parent
 * would invent a causal relationship that does not exist. Retry and dead-letter hops keep the
 * original trace so a message's whole journey stays one story.

injectMessageHeaders.platform(values)를 쓴다 — 예약 이름을 쓸 수 있는 factory다.

// inject:38-40
 * <p>Written as platform headers, not application headers, so that an application cannot
 * overwrite them and silently sever the trace.

messaging-core-api의 두 factory 분리(그쪽 §4.8)를 실제로 쓰는 두 번째 production 지점이다(첫 번째는 messaging-policyDeadLetterEnvelopeFactory).

shouldLinkRatherThanContinue(batchSize)batchSize > 1이다 — 단일 전달은 계속, 배치는 링크. 테스트가 두 경우를 각각 확인한다.

injecttraceparent가 비면 헤더를 건드리지 않고 그대로 반환한다. 활성 추적이 없을 때 빈 헤더를 만들지 않는다.

4.7 감사 — 메트릭과 분리된 이유
// MessagingAuditSink.java:10-13
 * <p>Separate from metrics and from application logs. An audit trail answers "who authorised this
 * destructive operation", which is a different retention, access, and integrity requirement from
 * "how slow was publish yesterday"; mixing them means either the audit gets dropped with the
 * metrics or the metrics inherit the audit's retention cost.

MessagingAuditEvent가 여섯 필드를 요구하고 넷은 빈 문자열을 거절한다 — operation, subject, destination, approvalTicket. 승인 티켓이 필수인 것이 설계다.

// MessagingAuditEvent.java:10-13
 * <p>Audit covers the operations that change state an application cannot: replay, redrive, offset
 * reset, purge, and delete. The subject is the operator identity and the details are passed through
 * {@link MessagingRedactor}, so an audit trail proves who did what without becoming a second copy
 * of the payload.

MessagingAuditSink.inMemory()CopyOnWriteArrayList 기반 구현을 준다 — "for tests and for a deployment that has no external audit store yet".

javadoc이 "details are passed through MessagingRedactor"라고 하지만 MessagingAuditEvent 생성자는 redactor를 부르지 않는다. Map.copyOf만 한다. 즉 redaction은 호출자 책임이고 타입이 강제하지 않는다 — §17.


5. 주요 실행 경로

메트릭: 호출자가 MessagingTags를 만들어 MessagingObservation의 다섯 메서드 중 하나를 호출 → MessagingMetrics.admitted(tags)guard.admit(tags) → 통과하면 Micrometer Tags로 변환 후 미터 기록, 거절되면 rejectedTagSets.increment()

추적(발행): tracer.inject(context, headers)traceparent 없으면 그대로 반환 → 있으면 세 헤더를 platform factory로 추가

추적(수신): tracer.extract(headers)traceparent 없으면 TraceContext.none() → 있으면 세 값으로 TraceContext 재구성(core-api의 W3C 검증을 통과해야 함)

감사: 호출자가 MessagingAuditEvent를 만들어 sink에 record


6. 실패 경로와 복구/번역

이 leaf는 MessagingException을 하나도 던지지 않는다. 실패를 값으로 표현한다.

상황 결과
태그 세트가 상한 초과 미터를 만들지 않고 messaging.tags.rejected 증가
진단 키가 상한 초과 그 키만 건너뜀
진단 키가 denylist sanitize가 제거
traceparent 없음 TraceContext.none()

IllegalArgumentException을 던지는 곳은 셋 — CardinalityGuard 생성자(limitPerDimension < 1), MessagingMetrics.recordDelivery(attempt < 1), MessagingTracer.shouldLinkRatherThanContinue(batchSize < 1), MessagingAuditEvent 생성자(빈 필드). 전부 호출자의 프로그래밍 오류다.

extract가 W3C 검증에 걸릴 수 있다. new TraceContext(traceparent, tracestate, baggage)가 core-api의 정규식·바이트 상한·all-zero 검사를 돌리므로(그쪽 §4.11), 다른 시스템이 보낸 손상된 traceparentIllegalArgumentException이 된다. 그 예외는 MessagingException이 아니고 extract는 그것을 잡지 않는다. messaging-cloudevents의 id 파싱과 같은 형태다(§A19-MESSAGING-CLOUDEVENTS §17). §17.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음. 이 leaf는 messaging family에서 messaging-transport-spi 다음으로 동시성이 조밀하다.

지점 도구 보호
CardinalityGuard.observed ConcurrentHashMap + ConcurrentHashMap.newKeySet() 차원별 값 집합
CardinalityGuard.admit 빠른 경로 contains + synchronized(values) 재확인 상한이 평균이 아니라 경계
MessagingMetrics.backlogs ConcurrentHashMap + computeIfAbsent gauge 한 번만 등록
MessagingMetrics.rejectedTagSets LongAdder 경합 하 카운트
MessagingAuditSink.InMemory.events CopyOnWriteArrayList 읽기 우세

MessagingRedactor·MessagingTracer·DefaultMessagingObservationConvention은 상태가 없다. MessagingTags는 불변 record다.

synchronized(values)Set 인스턴스를 락으로 쓴다.SetConcurrentHashMap.newKeySet()이고 외부에 노출되지 않으므로(observed 맵이 private) 외부 락 경합은 없다. 차원별로 락이 분리되는 효과도 있다.

수명주기 참여 없음.


8. 설정·기능 플래그·환경 차이

상수 위치
CardinalityGuard.DEFAULT_LIMIT 200 :21 (private)
MessagingTags.NONE "none" public
미터 이름 6개 messaging.* MessagingMetrics public 상수
연산 이름 4개 publish/consume/settle/deadLetter DefaultMessagingObservationConvention public 상수
W3C 헤더 3개 traceparent/tracestate/baggage MessagingTracer public 상수
denylist 27개 키 MessagingRedactor private

starter가 CardinalityGuard를 기본 생성자로 만든다(:264-265) — 상한 200이 설정 불가다.


9. 퍼시스턴스/외부 시스템 세부

없다. MeterRegistry가 유일한 외부 접점이고 인터페이스로 주입된다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-observability:test. BUILD SUCCESSFUL, 42 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
MessagingMetricCardinalityTest 8 상한 도달 시 미터 미생성, 거절 카운트, 첫 시도만 message counter 실제 backend 동작
MessagingRedactorTest 6 denylist 동작, sanitize/mask 차이
MessagingSecretLeakTest 9 금지 헤더 전수, 대소문자 무관, 메시지별 신원 제거, payload/예외 제거, 비밀이 meter registry에 도달하지 않음, 서로 다른 진단 값이 새 series를 만들지 않음, 경합 하 상한 유지, mask의 존재 신호 유지, 감사 이벤트가 payload를 안 담음
MessagingTraceLinkTest 8 브로커 홉 왕복, tracestate/baggage 보존, platform 헤더로 기록, 기존 헤더 보존, 추적 없음 처리, 단일=계속/배치=링크 실제 collector
SecretLeakStaticScanTest 6 messaging 소스 트리 전체를 정적 스캔 — 콘솔 출력 없음, 민감 식별자 문자열 연결 없음, 스캐너 자체 동작 3건 런타임 유출
SecretLeakScannerCharacterizationTest 5 스캐너 분류기의 현재 판정을 고정
10.1 정적 스캔 테스트

이 저장소에서 드문 형태다 — 테스트가 소스 트리를 읽는다.

// SecretLeakStaticScanTest.java:16-21
 * <p>A runtime redactor only protects the values that pass through it. A {@code toString()} that
 * concatenates a credential, or a log line that interpolates a payload, bypasses it entirely and is
 * invisible to every unit test  the leak only shows up in a production log, after the fact. A
 * static scan is the cheapest way to make that class of mistake fail in CI instead.

분류기가 네 단계로 오탐을 줄인다 — 문자열 리터럴 제거, + 주변 피연산자 추출, 안전한 파생(.length/.size/getSimpleName…) 제외, 산술(+ 1) 제외, 서술형 접미사(Id/Name/Count…) 제외.

theScanActuallyReachesTheSourceTree라는 테스트가 있다 — 스캔이 실제로 파일을 읽었는지 확인한다. 경로 탐색이 실패해 0개 파일을 스캔하고 통과하는 것을 막는다. 이 저장소가 반복하는 주제(게이트가 아무것도 검사하지 않는 것을 막기)의 좋은 예다.

10.2 특성화 테스트의 자기 서술

SecretLeakScannerCharacterizationTest의 javadoc이 자기 존재 이유와 제거 조건을 적는다.

// :12-27
 * Records exactly what {@link SecretLeakStaticScanTest}'s line classifier does today, so the fix
 * that removes its two false positives can be checked against the detection power it must keep.
 *
 * <p>The classifier below is a verbatim copy of the one under test. A characterization test that
 * called the real method would be the better design, and Wave 2 makes that possible by extracting
 * the classifier; until then a copy is the only way to assert on the decision procedure at all,
 * because every part of it is private and static. The copy is deleted in the same change that
 * proves the extracted classifier agrees with it.
 *
 * <p>Two cases here were the offenders that failed the full {@code test} run at HEAD, and naming
 * them as characterization turned "the build is red" into "the scanner cannot see a method call's
 * suffix, and cannot see that {@code + 1} is arithmetic".

분류기가 두 파일에 복제돼 있고, 그 복제를 지울 조건("Wave 2")이 명시돼 있으며, 그 Wave 2는 아직 일어나지 않았다. §12.3.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api"]
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 Micrometer가 MessagingMetrics 생성자에 등장 → api. 통과
SecretLeakStaticScanTest messaging 소스 트리 전체에 대해 콘솔 출력·민감 문자열 연결을 금지. :messaging-observability:test로 실행
ArchUnit 전용 규칙 없음

네 번째가 특이하다 — 한 leaf의 테스트가 family 전체 소스를 검사한다. 스캔 루트가 messaging-core-api 디렉터리를 찾아 올라가는 방식이므로 messaging 전체가 대상이다. 즉 이 leaf의 테스트 레인이 family 수준 게이트를 겸한다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/285-observability-tag-vocabulary-bypass.txt.

12.1 Public surface reachability

방법 주의. 단어 검색은 CardinalityGuard에서 오탐 9건을 냈다. 저장소에 같은 이름의 클래스가 둘 있다. 아래는 import로 확인한 값이다.

src/application-core/.../notification/platform/observation/CardinalityGuard.java:24  ← 다른 클래스
src/messaging/messaging-observability/.../observation/CardinalityGuard.java:19       ← 이 leaf

import 기준으로 이 leaf의 CardinalityGuard를 쓰는 파일은 한 개다(MessagingCoreAutoConfiguration:6). 나머지 다섯은 notification 쪽 동명 클래스를 import한다. messaging-schema-apiSchemaRegistry와 같은 함정이다(그쪽 §12.1).

교정 후 표:

타입 leaf 밖 소비자 판정
MessagingObservation 2 (DefaultMessagePublisher + 그 테스트) 사용됨
MessagingTags 2 (같음) 사용됨
MessagingAuditEvent 2 production (RedriveService, ReplayService) + 1 test 사용됨
MessagingRedactor 1 (starter bean) bean만
CardinalityGuard 1 (starter bean) bean만
MessagingMetrics 0 구현이 조립되지 않음
MessagingTracer 0
MessagingAuditSink 0
DefaultMessagingObservationConvention 0

(a) 관측 구현이 조립되지 않는다

MessagingMetricsMessagingObservation의 유일한 구현이고 저장소 전체에서 자기 테스트에서만 생성된다. starter는 그 두 생성자 인자(MessagingRedactor:253, CardinalityGuard:264)를 bean으로 만들고 그 둘을 합칠 bean은 만들지 않는다.

그리고 DefaultMessagePublisher는 6인자 생성자로 조립되어 NO_OBSERVATION을 쓴다. 상세는 §A19-MESSAGING-RUNTIME-CORE §12.1(b)가 소유한다. 이 leaf 쪽 사실은 구현·재료·seam이 다 있는데 조립만 없다는 것이다.

(b) 태그 어휘가 존재하고 유일한 호출부가 우회한다

DefaultMessagingObservationConvention은 "the tag values are a public contract … an adapter inventing its own spelling of 'rejected' silently breaks every alert"를 이유로 만들어졌고, 소비자가 0이다.

유일한 production 호출부가 이렇게 쓴다.

// DefaultMessagePublisher.observe:260-269
observation.recordPublish(
    MessagingTags.of(
        profile.broker(),
        profile.name().value(),
        "publish",                                              // ← 리터럴
        result.completion().name().toLowerCase(Locale.ROOT)),    // ← 직접 파생
    elapsedSince(startedAt));

두 가지가 어긋난다.

  1. "publish"가 리터럴이다. DefaultMessagingObservationConvention.PUBLISH 상수가 같은 값으로 존재한다.
  2. 4인자 MessagingTags.of(...)를 쓰므로 failureCategory가 항상 NONE이다. convention의 publish(broker, dest, completion, Optional<FailureCategory>)는 정확히 그 값을 채우려고 있다.

결과: 메트릭이 배선되더라도 실패한 발행의 실패 분류가 기록되지 않는다. MessagingTags가 6차원을 선언하고 실제로 채워지는 것은 4차원이다. retryStage도 마찬가지이지만 그쪽은 소비 경로가 없으므로 채울 주체 자체가 없다.

(c) 추적과 감사 sink는 소비자가 없다

MessagingTracer는 브로커 홉을 건너는 추적의 유일한 수단인데 참조가 0이다. 어댑터(messaging-kafka, messaging-rabbit)가 헤더를 매핑하지만 MessagingTracer를 쓰지 않는다 — 각 leaf SSOT가 무엇을 대신 하는지 답해야 한다.

MessagingAuditSink는 인터페이스 참조가 0이다. 그런데 MessagingAuditEventmessaging-admin-runtimeproduction에서 쓴다(RedriveService:126, ReplayService:73). 즉 이벤트 타입은 쓰고 sink 인터페이스는 안 쓴다 — §12.3(c).

한계. 정적 검색이다. 파생 프로젝트가 MessagingObservation 구현을 제공할 수 있으나, DefaultMessagePublisher의 6인자 조립을 대체하려면 publisher bean 전체를 바꿔야 한다(@ConditionalOnMissingBean(MessagePublisher.class)).

12.2 Conditional sibling comparison

이 leaf에 bean은 없다. starter 쪽 sibling 셋을 비교하면 비대칭이 드러난다.

starter가 만드는 것 조건 이 leaf 소속 주입처
MessagingRedactor (:253) @ConditionalOnMissingBean o 0
CardinalityGuard (:264) @ConditionalOnMissingBean o 0
MessagingMetrics o 만들지 않음

두 재료는 만들고 그것을 쓰는 것은 만들지 않는다. 조건은 동일하고 결과가 다르다. messaging-policyRetryDecisionEngine/DeadLetterOrchestrator(그쪽 §12.2)와 같은 형태이되, 여기서는 만들어진 것조차 주입처가 없다 — 더 이른 단계에서 끊겼다.

12.3 Duplicate mechanism sweep

(a) 자격증명 판정이 두 강도로 존재한다

위치 방식
messaging-core-api MessageHeaders.carriesACredential 정확 일치 9개 + 세그먼트 매칭 10개 + 인접 결합 x-api-key 거절, tokenizer-version 통과
이 leaf MessagingRedactor.isDenied 정확 일치 27개만 x-api-key 통과(목록에 없음)

MessagingRedactor의 denylist에 api_keyapikey는 있지만 x-api-key는 없다. core-api가 세그먼트 매칭으로 잡는 형태를 이쪽은 놓친다. 두 곳이 다른 표면을 보호하므로(헤더 vs 진단 맵) 같은 규칙일 필요는 없지만, 더 약한 쪽이 더 자유로운 입력을 받는다 — 진단 맵은 "the one place where a caller can pass arbitrary keys"라고 이 leaf 자신이 적는다. §17.

(b) 정적 스캐너 분류기가 두 파일에 복제돼 있다

SecretLeakStaticScanTest의 private static 분류기(5개 Pattern + 판정 로직)가 SecretLeakScannerCharacterizationTest글자 그대로 복사돼 있다. 후자의 javadoc이 그 사실과 제거 조건을 명시한다 — "The copy is deleted in the same change that proves the extracted classifier agrees with it." 그 change("Wave 2")는 일어나지 않았다.

의도된 임시 중복이고 조건이 문서화돼 있으므로 결함으로 분류하지 않는다. 다만 두 복사본이 갈라지면 특성화 테스트가 실제 스캐너와 다른 것을 고정하게 된다.

(c) 감사 sink 인터페이스가 사용처에서 다시 선언된다

// messaging-admin-runtime/RedriveService.java:208
void record(dev.caskeleton.messaging.observation.MessagingAuditEvent event);

MessagingAuditSink.record(MessagingAuditEvent)와 같은 시그니처다. messaging-admin-runtimeallowed_dependenciesmessaging-observability포함돼 있으므로 인터페이스를 쓸 수 있는데 쓰지 않는다.

결과: MessagingAuditSink.inMemory()가 제공하는 구현을 admin-runtime이 쓸 수 없고, 두 인터페이스가 구조적으로 호환되지만 타입 수준에서는 무관하다.

(d) 관측 seam이 family 밖에도 있다

notification 플랫폼이 자기 CardinalityGuard·NotificationObservationConvention·MicrometerNotificationMetrics를 갖는다. 같은 문제(카디널리티 경계 + 태그 어휘 + Micrometer 바인딩)를 두 family가 각자 푼다. 책임 경계가 다르므로 중복 경쟁은 아니지만, CardinalityGuard라는 이름이 겹쳐 reachability 판정에 오탐을 만들었다(§12.1). 저장소 전역 판단이므로 cross-scope가 소유한다.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
DefaultMessagingObservationConvention javadoc: "The conversion lives here, once, rather than at each call site" 소비자 0, 유일한 호출부가 리터럴 사용 불일치
MessagingAuditEvent javadoc: "the details are passed through MessagingRedactor" 생성자가 redactor를 부르지 않음 미강제 — 호출자 책임
MessagingMetrics javadoc: 태그가 guard를 먼저 통과 admitted(tags)가 모든 record 메서드의 첫 단계 일치
MessagingRedactor javadoc: denylist인 이유 27키 정확 일치 일치
MessagingTracer javadoc: platform 헤더로 기록 MessageHeaders.platform 사용 일치
build.gradle 주석: Micrometer가 public 생성자에 등장 MessagingMetrics(MeterRegistry, …) 일치
SecretLeakScannerCharacterizationTest javadoc: Wave 2에서 복사본 제거 복사본 존재 미실현
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift)

13. Git/설계 문서에서 확인한 변화와 실패 기록

위치 이전 상태 그것이 만든 실패
CardinalityGuard.admit 주석 size-then-add가 비원자적 N개 스레드가 각각 size == limit-1을 읽고 각각 추가 → 설정된 상한이 경계가 아니라 평균. "A guard that can be exceeded under load is no guard — load is when it matters."
CardinalityGuard.admit(tags) 주석 차원을 순회하며 즉시 커밋 마지막 차원에서 거절된 태그 세트가 앞 차원의 예산을 영구히 소비 — 방출된 적 없는 series에
MessagingMetrics.recordDiagnostics 주석 진단 키와 을 전부 카운터 태그로 요청당 고유 message id/예외 메시지/URL 하나가 요청당 미터 series 하나를 영구 생성. redactor는 아는 키만 마스킹하므로 자유형 텍스트는 그대로
SecretLeakScannerCharacterizationTest javadoc 스캐너가 메서드 호출 접미사와 + 1 산술을 구분 못 함 전체 test 실행이 red

세 번째가 가장 무겁다 — 경계가 있었는데 기본 차원만 보호했고 진단 값은 그 밖이었다. 현재는 값이 태그가 되지 않고 키만 별도 guard 차원("diagnostic")을 통과한다.

첫 두 개는 같은 주제의 두 형태다 — 경계는 예산을 정확히 소비할 때만 경계다. messaging-policy의 슬롯 누수 방지, messaging-transport-spiendWork clamp와 같은 계열이고 각 leaf §13이 소유한다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-285 command evidence/raw/285-observability-tag-vocabulary-bypass.txt 9타입 단어검색 원본값, CardinalityGuard 동명 클래스 둘과 import별 실제 소유자, 소비자 0인 네 타입, convention의 publish()MessagingTags.of()와 유일한 호출부 나란히, 감사 sink 재선언과 admin-runtime의 허용 의존 정적 검색. 파생 프로젝트 미포함
EVD-286 command ./gradlew :messaging:messaging-observability:test --rerun-tasks BUILD SUCCESSFUL, 42 / 0 / 0 SimpleMeterRegistry 사용. 실제 backend 없음

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 태그를 닫힌 record로 두는 이유와 무엇을 뺐는지 — MessagingTags javadoc
  • 태그 값이 공개 계약인 이유 — DefaultMessagingObservationConvention javadoc
  • 카디널리티 실패가 점진적이지 않은 이유, 조용한 대체보다 시끄러운 거절이 나은 이유 — CardinalityGuard javadoc
  • 두 개의 이전 경합/예산 결함 — 두 인라인 주석
  • denylist를 고른 이유와 두 범주 — MessagingRedactor javadoc
  • guard가 미터 생성보다 먼저인 이유 — MessagingMetrics javadoc
  • 논리 메시지와 물리 시도를 분리한 이유 — 같은 javadoc + MessagingObservation javadoc
  • 진단 값이 태그가 되지 않는 이유 — recordDiagnostics 주석
  • 메시징이 in-process 추적을 끊는 이유, W3C를 쓰는 이유 — MessagingTracer javadoc
  • 배치가 링크인 이유 — 같은 javadoc
  • 추적 헤더를 platform 헤더로 쓰는 이유 — inject javadoc
  • 감사를 메트릭·로그와 분리한 이유 — MessagingAuditSink javadoc
  • 정적 스캔이 필요한 이유 — SecretLeakStaticScanTest javadoc
  • 특성화 테스트의 복사본이 임시인 이유와 제거 조건 — 그 javadoc
  • Micrometer를 api로 선언한 이유 — build.gradle 주석

추론

  • MessagingMetrics bean이 없는 것이 미완인지 → 미상. 두 재료가 bean으로 있다는 점이 미완을 시사한다.
  • DefaultMessagePublisher가 convention을 쓰지 않는 것이 의도인지 → 미상.
  • 어댑터가 MessagingTracer 대신 무엇을 쓰는지 → 미확인(각 어댑터 leaf 소유).

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 9개 타입 838줄 전문의 계약
  • 42개 테스트가 통과하고 무엇을 단언하는지, 정적 스캔 테스트가 무엇을 검사하는지
  • CardinalityGuard가 동명의 다른 클래스와 혼동된다는 것과 import 기준 실제 소비자가 1개라는 것
  • MessagingMetrics·MessagingTracer·MessagingAuditSink·DefaultMessagingObservationConvention 넷이 소비자 0이라는 것
  • 태그 어휘가 존재하고 유일한 호출부가 리터럴과 4인자 factory로 우회하며, 그 결과 failureCategory가 항상 none이 된다는 것
  • starter가 MessagingMetrics의 두 재료만 bean으로 만든다는 것
  • MessagingAuditEvent는 admin-runtime이 쓰고 MessagingAuditSink는 재선언된다는 것

확인하지 못한 것

  • MessagingMetrics bean이 없는 것이 미완인지 확장점인지. 저장소 안에 답이 없다.
  • 어댑터들이 추적 헤더를 어떻게 다루는지 — MessagingTracer를 쓰지 않는 것은 확인했고 무엇을 대신 하는지는 각 leaf가 답한다.
  • SecretLeakStaticScanTest의 스캔 루트가 어떤 디렉터리 집합을 실제로 덮는지 — 코드상 messaging-core-api를 찾아 올라가지만 실행 시 파일 수를 남기지 않았다.
  • extract가 손상된 traceparent를 만났을 때의 실제 빈도.
  • CardinalityGuard 상한 200이 실제 배포에서 충분한지.

17. 손볼 것

P2 — 태그 어휘가 존재하고 유일한 호출부가 우회해, 실패 분류가 기록되지 않는다
  • 사실. DefaultMessagingObservationConvention은 소비자가 0이다. 유일한 production 호출부(DefaultMessagePublisher.observe:260-269)가 "publish" 리터럴과 4인자 MessagingTags.of(...)를 쓴다. 그 factory는 failureCategoryretryStageNONE으로 고정한다. convention의 publish(broker, dest, completion, Optional<FailureCategory>)는 정확히 failureCategory를 채우려고 존재한다.
  • 근거. evidence/raw/285 §C·§D.
  • 왜 문제인가. convention javadoc이 "an adapter inventing its own spelling of 'rejected' silently breaks every alert that was watching for it"를 이유로 중앙화를 선언했고, 첫 호출부가 그것을 지나쳤다. 그리고 결과가 철자 문제에 그치지 않는다 — MessagingTags가 선언한 6차원 중 4개만 채워진다. 메트릭이 배선되더라도(§다음 항목) 실패한 발행이 failureCategory=none으로 기록되어, "왜 실패했는가"를 메트릭에서 나눌 수 없다. PublishResult.failure()FailureDescriptor가 이미 있으므로 값은 손에 있다.
  • 확인 방법. evidence/raw/285 §D 재실행. 또는 git grep -n -w DefaultMessagingObservationConvention -- src.
  • 후보. observe(...)가 convention의 publish(profile.broker(), profile.name().value(), result.completion(), result.failure().map(FailureDescriptor::category))를 호출하게 바꾼다.
  • 다음 단계. CASE 후보. 그리고 "중앙 어휘는 첫 호출부가 쓸 때만 어휘다"가 REFERENCE 후보다.
P2 — 관측 구현이 조립되지 않고, 그 재료 둘만 bean으로 존재한다
  • 사실. MessagingMetricsMessagingObservation의 유일한 구현이고 저장소 전체에서 자기 테스트에서만 생성된다. starter는 그 생성자 인자 둘(MessagingRedactor:253, CardinalityGuard:264)을 bean으로 만들고 MessagingMetrics bean은 만들지 않는다. DefaultMessagePublisherNO_OBSERVATION을 쓰는 6인자 생성자로 조립된다.
  • 근거. evidence/raw/285 §A·§C. evidence/raw/283 §D(runtime-core 쪽 증거).
  • 왜 문제인가. 재료·구현·seam·호출부가 전부 있고 조립 한 줄이 없다. 그리고 두 재료 bean은 주입처가 0이므로 컨텍스트에 앉아만 있다 — bean 존재 검사는 통과한다.
  • 확인 방법. git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?MessagingMetrics\s*\(' -- src → 테스트만.
  • 다음 단계. §A19-MESSAGING-RUNTIME-CORE §17 첫 항목과 동일 사건이다. 그 leaf가 CASE를 소유하고 여기서는 이 leaf 쪽 사실(재료만 bean, 구현 미조립)을 기여한다.
P3 — 브로커 홉 추적기가 소비자를 갖지 않는다
  • 사실. MessagingTracer의 leaf 밖 참조 0. 이 클래스가 존재하는 이유는 "the only way the two spans meet is if the context travels in the message headers"다.
  • 근거. evidence/raw/285 §C.
  • 왜 문제인가. messaging-core-apiTraceContext가 봉투 필드로 있고(그쪽 §4.11), 어댑터가 헤더를 매핑한다. 그런데 traceparent/tracestate/baggage를 헤더로 옮기는 명시된 수단을 아무도 쓰지 않는다. 어댑터가 각자 하고 있다면 MessageHeaders.platform 사용 여부와 빈 추적 처리가 어댑터마다 다를 수 있다.
  • 확인 방법. git grep -n -w MessagingTracer -- src → 이 leaf만. 어댑터의 헤더 매퍼가 세 이름을 어떻게 다루는지 확인 필요.
  • 후보. 어댑터가 MessagingTracer를 쓰게 하거나, 어댑터가 대신 하고 있음을 확인하고 이 클래스를 정리한다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 messaging-kafka·messaging-rabbit leaf의 사실에 걸린다.
P3 — 감사 sink 인터페이스가 사용처에서 다시 선언된다
  • 사실. MessagingAuditSink.record(MessagingAuditEvent)와 같은 시그니처를 RedriveService:208이 자기 중첩 인터페이스로 선언한다. messaging-admin-runtimemessaging-observability에 의존할 수 있다(registry 확인).
  • 근거. evidence/raw/285 §E.
  • 왜 문제인가. MessagingAuditSink.inMemory()가 제공하는 구현을 admin-runtime이 쓸 수 없다. 그리고 감사 sink의 계약(분리된 보존·접근·무결성 요구)이 문서화된 곳과 실제로 구현되는 곳이 다르다.
  • 확인 방법. 두 시그니처 대조.
  • 후보. RedriveServiceMessagingAuditSink를 받게 한다.
  • 다음 단계. CASE 후보. messaging-admin-runtime leaf SSOT와 공동 소유.
P3 — 자격증명 판정이 core-api보다 약하다
  • 사실. MessagingRedactor.isDenied는 27키 정확 일치다. messaging-core-apiMessageHeaders.carriesACredential은 세그먼트 매칭 + 인접 결합으로 x-api-key·auth-token·db_password를 잡는다. redactor의 denylist에 api_key·apikey는 있으나 x-api-key는 없다.
  • 근거. 두 구현 대조. MessagingRedactor.java:21-50, MessageHeaders.java:142-160.
  • 왜 문제인가. 두 표면이 다르지만 더 자유로운 입력을 받는 쪽이 더 약하다. 이 leaf 자신이 진단 맵을 "the one place where a caller can pass arbitrary keys"라고 부른다. 그리고 recordDiagnostics가 redaction을 첫 단계로 두는 이유가 바로 그것이다.
  • 확인 방법. redactor.isDenied("x-api-key")가 false임을 확인.
  • 후보. core-api의 세그먼트 매칭을 공유하거나 이쪽 denylist를 같은 방식으로 바꾼다.
  • 다음 단계. CASE 후보 + REFERENCE 후보(같은 규칙을 두 강도로 구현하면 자유로운 입력 쪽을 강한 것으로 맞춘다).
P3 — 감사 이벤트가 redaction을 강제하지 않는다
  • 사실. MessagingAuditEvent javadoc이 "the details are passed through MessagingRedactor"라고 하지만 생성자는 Map.copyOf만 한다.
  • 근거. MessagingAuditEvent.java:30-38.
  • 왜 문제인가. 감사 기록은 "often retained far longer than the source topic"이고 운영자가 읽는다. redaction이 호출자 책임이면 새 호출부가 그것을 잊을 수 있다. messaging-core-apiFailureDescriptor가 512자 절단을 생성자에서 하는 것과 대비된다.
  • 확인 방법. 생성자 본문 확인. RedriveService:126·ReplayService:73이 redactor를 부르는지 확인.
  • 후보. 생성자가 MessagingRedactor.sanitize를 적용하거나, javadoc을 "호출자가 통과시켜야 한다"로 고친다.
  • 다음 단계. REFERENCE 후보(타입이 문서화한 불변식은 타입이 강제한다).
P3 — extract가 손상된 추적 헤더에 분류되지 않은 예외를 던진다
  • 사실. MessagingTracer.extractnew TraceContext(...)를 부르고, 그 생성자는 W3C 문법·바이트 상한·all-zero를 검사해 IllegalArgumentException을 던진다. extract는 잡지 않는다.
  • 근거. MessagingTracer.java:67-75, TraceContext.java:60-72.
  • 왜 문제인가. 다른 시스템이 보낸 메시지의 헤더는 신뢰할 수 없는 입력이다. 손상된 traceparent 하나가 MessagingException이 아닌 예외로 소비 경로를 끊는다 — 추적이 없어야 할 자리에서 메시지 처리가 실패한다. messaging-cloudevents의 id 파싱과 같은 형태다(그쪽 §17).
  • 확인 방법. tracer.extract에 잘못된 traceparent 헤더를 넣어 확인.
  • 후보. extract가 검증 실패를 TraceContext.none()으로 강등한다 — 추적 손실이 메시지 손실보다 낫다.
  • 다음 단계. CASE 후보. 다만 MessagingTracer 소비자가 0이므로 오늘의 사고는 아니다.
확인된 설계(문제 아님)
  • 태그를 닫힌 6차원 record로 두고 message id·partition key·tenant·offset을 명시적으로 배제한 것
  • guard가 미터 생성보다 먼저이고, 거절을 고정 이름 미터로만 기록하는 것
  • 논리 메시지 카운터를 첫 시도에만 증가시키는 것
  • 진단의 을 태그로 만들지 않고 키만 별도 차원으로 세는 것, redaction을 첫 단계로 두는 것
  • 경합 하에서도 상한이 경계로 유지되는 double-checked 구조와, 그것을 재현하는 테스트
  • 태그 세트를 사전 확인 후 커밋해 거절된 세트가 예산을 안 먹게 하는 것
  • 추적을 platform 헤더로 써서 애플리케이션이 덮지 못하게 하는 것
  • 배치 소비를 부모가 아니라 링크로 두는 것
  • 감사를 메트릭·로그와 분리하고 승인 티켓을 필수로 둔 것
  • 소스 트리를 정적 스캔하는 테스트와, 그 스캔이 실제로 파일을 읽었는지 확인하는 테스트
  • Micrometer를 api로 선언하고 seam은 벤더 중립으로 유지한 것

Source anchors

id kind path revision what it proves limitations
MOB-001 registry src/config/architecture/modules.json 21234e38 deps 1개, memberships ["app-bootstrap"] 선언
MOB-002 build messaging-observability/build.gradle same Micrometer api와 그 이유
MOB-003 code .../observation/MessagingTags.java same 닫힌 6차원, 두 factory의 차이
MOB-004 code .../observation/DefaultMessagingObservationConvention.java same 태그 어휘 중앙화 의도 소비자 0(§12.1b)
MOB-005 code .../observation/CardinalityGuard.java same 상한 강제와 두 이전 결함 동명 클래스 존재(§12.1)
MOB-006 code .../observation/MessagingRedactor.java same 27키 denylist, sanitize/mask core-api보다 약함(§17)
MOB-007 code .../observation/MessagingMetrics.java same 여섯 미터, guard 우선 순서, 진단 값 배제 조립되지 않음
MOB-008 code .../observation/MessagingObservation.java same seam 5메서드
MOB-009 code .../observation/MessagingTracer.java same W3C 왕복, platform 헤더, 배치 링크 소비자 0
MOB-010 code .../observation/{MessagingAuditSink,MessagingAuditEvent}.java same 감사 분리와 필수 필드 sink 소비자 0, redaction 미강제
MOB-011 test MessagingSecretLeakTest (9) same 비밀·신원이 meter registry에 도달 못 함, 경합 하 상한 SimpleMeterRegistry
MOB-012 test MessagingMetricCardinalityTest (8) same 상한 동작과 거절 카운트
MOB-013 test MessagingTraceLinkTest (8) same 추적 왕복과 링크 판정 실제 collector 없음
MOB-014 test MessagingRedactorTest (6) same denylist 동작
MOB-015 test SecretLeakStaticScanTest (6) same messaging 소스 전체 정적 스캔 + 스캔 도달 확인 런타임 유출 미포함
MOB-016 test SecretLeakScannerCharacterizationTest (5) same 분류기 판정 고정 분류기 복사본(§12.3b)
MOB-017 assembly messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:253,264 same 두 재료 bean, MessagingMetrics 부재 해당 leaf SSOT가 소유
MOB-018 cross-leaf code messaging-runtime-core/.../DefaultMessagePublisher.java:258-269 same 유일한 관측 호출부와 그 우회 해당 leaf SSOT가 소유
MOB-019 cross-leaf code messaging-admin-runtime/.../RedriveService.java:126,208, ReplayService.java:73 same 이벤트 사용, sink 재선언 해당 leaf SSOT가 소유
EVD-285 command evidence/raw/285-observability-tag-vocabulary-bypass.txt same §12.1·§12.3(c) 정적 검색
EVD-286 command ./gradlew :messaging:messaging-observability:test --rerun-tasks same 42 / 0 / 0 SimpleMeterRegistry

A19-MESSAGING-OUTBOX-JDBC-POSTGRESQL. messaging-outbox-jdbc-postgresql

분석 중에는 messaging/MESSAGING-OUTBOX-JDBC-POSTGRESQL.md 파일이었다. 1,002줄.

messaging-outbox-jdbc-postgresql 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-outbox-jdbc-postgresql SSOT owner: messaging-outbox-jdbc-postgresql integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-outbox-jdbc-postgresql
  • canonical state analysisFile: §A19-MESSAGING-OUTBOX-JDBC-POSTGRESQL
  • source path: src/messaging/messaging-outbox-jdbc-postgresql
  • registry allowed_dependencies: ["messaging-core-api", "messaging-reliability-api", "messaging-policy", "messaging-observability", "messaging-admin-api"]
  • registry runtime_memberships: ["app-bootstrap"] — 배포된다
숫자
항목
production Java 파일 13
test Java 파일 8
전체 LOC (Java) 4,416
SQL 마이그레이션 4 (V1~V4)
기타 리소스 1 (debezium/outbox-event-router.properties)
test 메서드(실행 확인) 76 (EVD-313)
그중 컨테이너 IT 28 (Postgres 21 + admin journal 7) — 실제 실행됨
선언된 의존 project 5 + vendor 2(impl) + vendor 4(test)
leaf 밖에서 import 하는 파일 3 (starter 2 + app-bootstrap 계약 테스트 1)

13개 production 타입:

타입 LOC 역할 src/main 생성
JdbcOutboxRepository 722 OutboxRepository 의 PostgreSQL 구현 0
JdbcAdminOperationJournal 326 AdminOperationJournal 의 PostgreSQL 구현 0
OutboxRelay 231 한 번의 릴레이 패스 1 (starter)
OutboxRelayWorker 199 패스를 스케줄링·구동 1 (starter)
DebeziumOutboxEventRouter 151 CDC 커넥터 설정·헤더 매핑 1 (자기 참조)
OutboxRetryScheduler 134 백오프와 시도 예산 2
OutboxEnvelopeFactory 124 행 → 발행 봉투 0
OutboxProperties 81 설정과 그 사이의 불변식
DebeziumOutboxRecordMapper 79 CDC 가 낼 레코드의 모델 0
DebeziumOutboxProfile 67 릴레이 모드 선택 + 상호배제 1 (자기 팩토리)
OutboxCleanupJob 58 보존기간 지난 PUBLISHED 행 삭제 1 (starter)
DebeziumMappedRecord 54 CDC 출력 레코드
OutboxRelayReport 50 패스 1회 결과
Coverage ledger
scope/file group count disposition reason
src/main/java/** (13) 13 FULL_READ 전 파일 본문 확인
src/main/resources/db/migration/** (4) 4 FULL_READ V1~V4 전문
src/main/resources/debezium/*.properties (1) 1 FULL_READ 43줄 전문
src/test/java/** (8) 8 STRUCTURAL_ONLY 76개 테스트 메서드 인벤토리 전수 + 판정에 필요한 구간(대역 구현, purge·Debezium·이스케이프 단언)만 본문 확인. 전 파일 축자 통독은 하지 않았다
build.gradle 1 FULL_READ 24줄
build/** EXCLUDED 빌드 산출물 (단, jshell 탐침에 컴파일된 클래스를 사용 — EVD-314)

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

트랜잭셔널 아웃박스의 PostgreSQL 구현이다. 비즈니스 트랜잭션이 쓰고 릴레이가 배출한다. 여기에 더해 messaging-admin-api 의 파괴적 작업 저널 구현도 같이 산다 — 그 이유가 build.gradle 에 적혀 있다.

// build.gradle:9-13
// The destructive-operation journal lives here because it needs exactly what the outbox needs:
// one relational database every replica can see, and a migration lane that already exists. The
// contract it implements belongs to the admin API.
api project(':messaging:messaging-admin-api')

이 리프의 축은 하나다: "모르는 것을 실패로 취급하지 않는다."

// OutboxRelay.java:17-27
/**
 * Publishes outbox rows, treating an unknown outcome as retryable rather than final.
 *
 * <p>The relay's correctness rests on one rule: an ambiguous publish is retried <em>under the same
 * message id</em>. Minting a new id would turn a possibly-delivered message into a
 * definitely-second message, and no downstream deduplication could recover from it. Marking it
 * failed instead would lose a message the broker may already hold.
 *
 * <p>The relay therefore guarantees at-least-once publication and nothing more. Effectively-once
 * downstream effects come from pairing it with an Inbox — which is why the platform never
 * advertises the outbox as exactly-once.
 */

마지막 문장이 중요하다 — 이 리프가 자기 보장의 상한을 스스로 명시한다.

경계: 브로커를 모른다(MessagePublisher 포트만 안다). 스프링 컨텍스트를 모른다(spring-jdbc/spring-tximplementation 이며 트랜잭션 동기화 조회에만 쓴다). 배선은 starter 몫이다.


2. 의존성과 런타임 배선

// build.gradle 전문 (24줄)
apply plugin: 'java-library'

dependencies {
    api project(':messaging:messaging-core-api')
    api project(':messaging:messaging-reliability-api')
    api project(':messaging:messaging-policy')
    api project(':messaging:messaging-observability')
    api project(':messaging:messaging-admin-api')      // + 위 주석

    implementation 'org.springframework:spring-jdbc'
    implementation 'org.springframework:spring-tx'

    // Live-database certification. The reliability patterns are claims about transaction
    // boundaries and uniqueness constraints, and only a real database can settle them.
    testImplementation project(':messaging:messaging-testkit')
    testImplementation 'org.testcontainers:testcontainers-postgresql'
    testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
    testImplementation 'org.postgresql:postgresql'
}

testcontainers 주석이 이 리프의 성격을 요약한다 — "신뢰성 패턴은 트랜잭션 경계와 유일성 제약에 대한 주장이고, 그것을 결판낼 수 있는 것은 실제 데이터베이스뿐이다." 그리고 그 레인이 실제로 돈다(§10).

starter 가 만드는 빈(EVD-312):

// MessagingReliabilityAutoConfiguration.java
:63   new OutboxRetryScheduler(properties, Duration.ofMinutes(1))
:89   new OutboxRelay(...)
:109  new OutboxRelayWorker(relay, scheduler)
:141  new OutboxCleanupJob(outbox, properties, 20)
:170  new InboxCleanupJob(inbox, policy, 20)
// MessagingOutboxRelayLifecycle.java
:42   worker.start();

starter 가 만들지 않는 것: JdbcOutboxRepository, OutboxEnvelopeFactory, JdbcAdminOperationJournal. 셋 다 애플리케이션이 DataSource/ProducerId 를 알고 직접 등록해야 한다. AdminOperationJournal 의 기본값은 InMemoryAdminOperationJournal 이며, 프로덕션 프로파일에서는 MessagingAdminDurabilityValidator 가 그것을 거부한다(§A19-MESSAGING-ADMIN-RUNTIME §4.4 참조).


3. 패키지/컴포넌트 지도

단일 패키지 dev.caskeleton.messaging.outbox. 두 갈래의 배출 경로가 있고, 한쪽만 살아 있다.

  [비즈니스 트랜잭션]
        |  JdbcOutboxRepository.append(record)   — 호출자의 커넥션에 합류, 없으면 거절
        v
   messaging_outbox 테이블
        |
        +--- 경로 A: 폴링 릴레이 (배선됨)
        |      OutboxRelayWorker.start()  ->  runPass()
        |        -> OutboxRelay.runOnce(now)
        |             claimBatch(owner, batchSize, lease, now, maxAttempts)   FOR UPDATE SKIP LOCKED
        |             -> OutboxEnvelopeFactory.toEnvelope(row)
        |             -> MessagePublisher.publish(...)
        |             -> markPublished / markAmbiguous / markExhausted / markFailed  (펜싱 술어)
        |        -> OutboxRetryScheduler.backoff(unproductivePasses)
        |
        +--- 경로 B: CDC 릴레이 (배선 안 됨 — §12.1)
               DebeziumOutboxProfile(CHANGE_DATA_CAPTURE, prefix, flag)
                 -> DebeziumOutboxRecordMapper.map(row) -> DebeziumMappedRecord   [모델]
                 -> DebeziumOutboxEventRouter.connectorConfiguration(prefix)      [Java 설정]
               debezium/outbox-event-router.properties                            [배포 설정 — 드리프트]

  messaging_admin_operation 테이블
        |  JdbcAdminOperationJournal   (begin/checkpoint/complete/fail/find)

4. 계약·불변식·상태 모델

4.1 스키마 — 마이그레이션 4개가 이력을 담고 있다

V1message_id 를 대리키가 아니라 기본키로 삼는다.

-- V1__messaging_outbox.sql:3-5
-- Written by the business transaction, drained by the relay. message_id is the primary key rather
-- than a surrogate: it is the logical identity the relay must preserve across every retry, and
-- making it the key means no code path can accidentally publish the same row under a new id.

인덱스도 근거가 있다. 부분 인덱스인 이유("PUBLISHED rows accumulate until the retention job removes them"), IN_FLIGHT 를 포함하는 이유("A relay that dies mid-publish leaves rows in that state ... omitting them here would strand those messages").

V2 — 펜싱 토큰. 주석이 시나리오를 그대로 적는다.

-- V2__messaging_outbox_lease_fencing.sql:3-14
-- V1 recorded only lease_expires_at, so a claim said when it would end and nothing about who held
-- it. ... :
--   relay A claims the row and calls the broker
--   the lease expires; relay B reclaims it, publishes, and records PUBLISHED
--   relay A finally times out and records AMBIGUOUS over the top
-- The row is now claimable again and the message is published a second time. Making the lease
-- longer than the publish timeout lowers the odds; it does not turn a GC pause, a scheduler stall
-- or a slow broker into a data constraint. A token does ...

"확률을 낮추는 것과 데이터 제약으로 만드는 것은 다르다" — 이 리프에서 가장 좋은 한 줄이다. EXHAUSTED 상태 추가와 next_attempt_at 인덱스도 여기서 들어온다.

V3 — admin 저널. 복합 기본키 (approval_ticket, plan_digest) 의 근거가 messaging-admin-api 의 것과 동일하게 적혀 있다.

V4 — 정경 메타데이터 12컬럼. 왜 봉투 blob 이 아니라 컬럼인지가 명확하다.

-- V4:11-14
-- Columns rather than a versioned envelope blob. Both round-trip the values faithfully; only one of
-- them lets the relay answer an operator's questions. "Which tenant is the backlog for", "which
-- correlation is stuck", "which rows carry a schema this consumer cannot read" are SELECTs against
-- this table if the fields are columns, and payload decoding of the whole backlog if they are not.

그리고 밀반입 문제를 명시한다 — "smuggled through the header map under the reserved msg.* names ... a row whose header map contains msg.id overwrites another message's identity on the wire".

DB 레벨 제약을 Java 와 이중으로 거는 이유도 적혀 있다.

-- V4:33-36
-- The same bound TenantContext enforces in Java. Stated here as well because the relay, the CDC
-- connector and any operator query read this table directly: a tenant slug that only the
-- application validates is a tenant slug that an INSERT from anywhere else can violate ...
ALTER TABLE messaging_outbox ADD CONSTRAINT ck_messaging_outbox_tenant
    CHECK (tenant IS NULL OR tenant ~ '^[a-z0-9][a-z0-9._-]{0,63}$');

마지막으로 생성 컬럼이 두 릴레이의 합의를 하나로 만든다.

-- V4:55-66
-- Debezium's Event Router takes the message key from a column. It was pointed at `destination`,
-- which made the key the topic name — every message on a topic sharing one key, so every message
-- landing on one partition, and keyed ordering meaning nothing. The polling relay meanwhile used
-- the partition key when the row had one and the message id when it did not.
--
-- A generated column states that fallback once, in the place both relays read, instead of leaving
-- it as a rule each of them implements separately and one of them gets wrong.
ALTER TABLE messaging_outbox
    ADD COLUMN routing_key TEXT GENERATED ALWAYS AS (COALESCE(partition_key, message_id::TEXT)) STORED;

이 수정이 배포되는 properties 파일에는 도달하지 않았다. §12.4(a).

4.2 append — 이 리프의 전체 메커니즘
// JdbcOutboxRepository.java:37-46
/**
 * <p>{@link #append} deliberately takes no connection of its own: it uses the one the caller is
 * already inside, which is the entire mechanism. An outbox row written on a separate connection
 * commits independently of the business change and reopens the window the pattern exists to close.
 */

그리고 그것을 강제한다.

// :203-218
requireActiveTransaction("OUTBOX_TRANSACTION_REQUIRED", "appending to the outbox");
Connection connection = DataSourceUtils.getConnection(dataSource);

세 가지를 본다(:228-245): 활성 트랜잭션이 있는가 / 읽기 전용이 아닌가 / 이 DataSource 에 바인딩되어 있는가. 세 번째가 특히 좋다 — 다른 DataSource 의 트랜잭션 안에서 append 하면 둘이 독립적으로 커밋된다.

// :221-227
/**
 * <p>Fail-fast rather than "work anyway": an append that silently runs outside the caller's
 * transaction produces exactly the ghost publication this repository exists to prevent, and it
 * produces it only on the rollback path — which is the path nobody exercises before production.
 */

append(Connection, OutboxRecord) 가 package-private 으로 내려간 이력도 적혀 있다(:155-165) — 예전에는 그것이 public 이었고 "안전한 경로가 호출자가 알아야만 하는 경로" 였다.

4.3 청구(claim)와 펜싱 — 두 세대가 공존한다

신세대 CLAIM(:112-142)은 소유자와 토큰을 기록하고 재시도 시계를 술어에 포함한다.

WHERE status IN ('PENDING', 'AMBIGUOUS', 'IN_FLIGHT')
  AND (lease_expires_at IS NULL OR lease_expires_at <= ?)
  -- The retry clock lives in the row, not in the relay's memory. Without these two
  -- predicates an AMBIGUOUS row became claimable again on the very next pass, so a
  -- broker outage meant the whole backlog was republished every poll interval and the
  -- configured attempt budget was a number nothing consulted.
  AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
  AND attempts < ?
ORDER BY created_at LIMIT ? FOR UPDATE SKIP LOCKED
...
SET status='IN_FLIGHT', lease_expires_at=?, lease_owner=?, lease_token = o.lease_token + 1

토큰 증가가 청구와 같은 문장 안에서, 서버에서 일어난다 — "two relays racing for the same row cannot receive the same number"(:106-111).

종결 쓰기는 전부 펜싱 술어를 단다.

// :400-403
String sql = setClause
    + "WHERE message_id = ? AND status = 'IN_FLIGHT' AND lease_owner = ? AND lease_token = ?";

그리고 0행을 삼키지 않는다.

// :392-398
/**
 * <p>The predicate carries the owner and the token as well as the id, so a relay that stalled
 * past its lease writes nothing: another relay's claim incremented the token, and this update
 * matches zero rows. Zero is reported rather than swallowed — a stale write means this worker may
 * have produced a duplicate publication, which is exactly what an operator needs to see.
 */

구세대 LEASE(:80-104)와 markPublished(MessageId) / markAmbiguous(MessageId, ...) / markFailed(MessageId, ...) / releaseLease(MessageId) 는 소유자·토큰을 다루지 않는다. 그리고 남기는 행 상태가 다르다(§12.3(a)).

4.4 OutboxRelay.runOnce — 세 결과, 다섯 카운터
// :169-218 (요약)
switch (result.completion()) {
  case CONFIRMED -> markPublished(lease, now)                       APPLIED? published++ : stale++
  case AMBIGUOUS -> {
      int spent = record.attempts() + 1;
      scheduler.parkReason(spent)
        .map(reason -> markExhausted(lease, reason, now))
        .orElseGet(() -> markAmbiguous(lease, code, now, scheduler.nextAttemptAt(now, spent)));
      APPLIED? (isExhausted(spent) ? exhausted++ : ambiguous++) : stale++
  }
  case REJECTED  -> markFailed(lease, code, now)                    APPLIED? failed++ : stale++
  default -> throw new IllegalStateException("unhandled publish completion: " + );
}

spent = attempts + 1 의 근거가 붙어 있다.

// :179-181
// The attempt this pass just spent. The claim predicate and the row both count attempts
// after the transition, so the budget has to be judged on the same number the next claim
// will read, or the last attempt is spent twice.

EXHAUSTED 를 별도 상태로 두는 근거도.

// :186-188
// A row that has spent its budget without an answer is parked under its own
// status. Leaving it AMBIGUOUS makes it a row the claim predicate silently skips
// forever, which looks identical to a healthy backlog on every dashboard.

default -> 분기의 존재 이유까지 적혀 있다(:213-215) — 새 completion 상수가 생기면 조용히 IN_FLIGHT 로 남기는 대신 크게 실패하도록.

OutboxRelayReport 의 다섯 카운터가 각각 다른 운영 신호라는 것도 명시적이다(:5-18) — ambiguous 는 확인 문제, failed 는 계약/토폴로지 문제, staleLeases 는 "중복 발행의 가시화된 형태", exhausted 는 "redrive 가 필요한 것".

4.5 OutboxProperties — 설정 간의 관계를 생성자가 강제한다
// :7-14
/**
 * <p>The lease duration is the dangerous one. If it is shorter than the time a publish can take, a
 * second relay claims the row while the first is still waiting for a confirm, and the message is
 * published twice — under the same id, so consumers with an inbox survive it, but consumers without
 * one do not. The constructor therefore requires the lease to exceed the publish timeout by a
 * margin rather than merely to be positive.
 */
public static final double REQUIRED_LEASE_FACTOR = 2.0;

leaseDuration >= publishTimeout * 2 를 생성자가 강제하고 OUTBOX_LEASE_TOO_SHORT 로 거절한다. 기본값(30초 / 5초)이 그 규칙을 만족하는지 자체 테스트가 있다(theDefaultsSatisfyTheirOwnRule).

4.6 OutboxEnvelopeFactory — 정경 사실을 컬럼에서 되살린다
// :20-37
/**
 * <p>The identity comes from the row, never from a fresh mint. ...
 *
 * <p>So does everything else the envelope carries. This used to rebuild correlation, causation,
 * tenant, trace and the schema reference as empty, and read the routing keys out of the row's
 * header map — so a message that travelled through the outbox reached its consumer with less
 * provenance than one published directly, and the publish path became part of the message's
 * meaning. ...
 *
 * <p>Reserved header names in the row are refused outright, with no exception for the routing keys.
 * ... Now that the keys are columns, the rule is the simple one: an outbox row cannot write into
 * the platform's namespace at all.
 */

예약 이름을 만나면 RESERVED_HEADER_IN_OUTBOX_ROW던진다(:70-77). 부재 값 처리도 정직하다 — occurredAt 이 없으면 createdAt 을 쓰고 그 이유를 적는다("the business transaction that wrote the row is the one the fact occurred in", :87-89), producer 가 없으면 릴레이 소유 서비스로 귀속한다(:91-92).

4.7 JdbcAdminOperationJournal — DB 제약이 경쟁을 결판낸다
// :22-32
/**
 * <p>Lives beside the outbox because it needs the same thing the outbox needs and nothing more: one
 * relational database that every replica can see. The uniqueness that stops a second execution is
 * the primary key on {@code (approval_ticket, plan_digest)}, enforced by the database rather than
 * by a check-then-act in application code — two replicas that read "no row" at the same instant
 * would both proceed, and only the constraint makes exactly one of them win.
 */

INSERT ... ON CONFLICT DO NOTHING 이 1행이면 신규 청구, 0행이면 기존 행을 읽어 refuseIfNotResumableTAKE_OVER. 인수 SQL 자체가 조건을 담는다.

WHERE approval_ticket = ? AND plan_digest = ? AND lease_token = ?
  -- Only a failed operation or one whose lease ran out may be taken over. A live STARTED row
  -- means another replica is executing it right now.
  AND (state = 'FAILED' OR lease_expires_at <= ?)
RETURNING lease_token, items_completed

읽기와 인수 사이의 경쟁도 처리한다 — RETURNING 이 0행이면 "another replica took it over between the read and this update"(:181-186)로 거절.

그리고 items_completedGREATEST 로 단조 증가한다(CHECKPOINT/SETTLE SQL). 이것이 DefaultMessagingAdminService 가 낡은 값을 넘겨도 진행이 되돌아가지 않는 이유이며, 인터페이스가 요구하지 않는 성질이라는 점은 §A19-MESSAGING-ADMIN-RUNTIME §12.4(c)에 있다.


5. 주요 실행 경로

쓰기 — 비즈니스 트랜잭션 → append(record) → 트랜잭션 3중 검사 → DataSourceUtils.getConnection → INSERT(22컬럼).

배출MessagingOutboxRelayLifecycleworker.start()runPass()relay.runOnce(now) → 청구/발행/종결 → scheduler.backoff(unproductive) → 다음 패스 자기 스케줄링.

정리OutboxCleanupJob.runOnce(now)cutoff = now - retentionpurgePublishedBefore(cutoff) 무제한 오버로드 ×(최대 maxBatches, 실제로는 2회) → §12.1(a).

admin 저널begin → INSERT ON CONFLICT / TAKE_OVER → checkpoint × N → complete 또는 fail.


6. 실패 경로와 복구/번역

상황 처리 위치
트랜잭션 없이 append OUTBOX_TRANSACTION_REQUIRED JdbcOutboxRepository:228-235
읽기 전용 트랜잭션 :236-239
다른 DataSource 의 트랜잭션 :240-246
append SQL 실패 OUTBOX_APPEND_FAILED :196-199
그 밖의 쿼리 실패 OUTBOX_QUERY_FAILED :594-600
종결 쓰기가 0행 OutboxTransitionResult.STALE_LEASE (예외 아님) :413-415
미지의 PublishCompletion IllegalStateException OutboxRelay:216-217
리스가 발행 타임아웃보다 짧음 OUTBOX_LEASE_TOO_SHORT OutboxProperties:52-58
행 헤더에 예약 이름 RESERVED_HEADER_IN_OUTBOX_ROW OutboxEnvelopeFactory:70-77
승인 이미 실행됨 APPROVAL_ALREADY_EXECUTED JdbcAdminOperationJournal:117-124
다른 런타임이 실행 중 ADMIN_OPERATION_IN_FLIGHT :125-132, :181-186
리스 상실 후 쓰기 ADMIN_OPERATION_LEASE_LOST :263-271
저널 도달 불가 ADMIN_JOURNAL_UNAVAILABLE :206-208
두 릴레이 동시 활성 DUPLICATE_OUTBOX_RELAY DebeziumOutboxProfile:54-59 (호출부 0)
릴레이 없음 NO_OUTBOX_RELAY :60-65 (호출부 0)

OutboxRelayWorker 의 패스 실패 처리가 특히 명시적이다.

// :184-190
} catch (RuntimeException passFailed) {
  // A failed pass must not stop the loop: the scheduled task's own exception would cancel every
  // future pass, turning one broker error into a relay that never runs again. The failure is
  // counted and the next pass backs off as if nothing was published, which is true.

종료도 인터럽트가 아니라 드레인이다.

// :99-106
/**
 * <p>Draining rather than interrupting is the whole point. A pass killed between its claim and
 * its terminal write leaves rows {@code IN_FLIGHT} holding a lease, and nothing may touch them
 * until that lease expires — so an orderly shutdown would produce exactly the stall that a crash
 * produces.
 */

7. 트랜잭션·동시성·수명주기

두 가지 커넥션 획득 방식이 공존한다.

메서드 획득 효과
JdbcOutboxRepository.append(record) DataSourceUtils.getConnection 호출자 트랜잭션에 합류
그 외 전부 (withConnection) dataSource.getConnection() + try-with-resources 풀에서 새 커넥션, 독립 커밋
JdbcAdminOperationJournal 전 메서드 DataSourceUtils.getConnection 트랜잭션 있으면 합류

릴레이 연산이 비즈니스 트랜잭션에 합류하면 안 되므로 withConnection 의 선택은 타당하다. 다만 그 판단이 주석으로 남아 있지 않고, 같은 리프의 저널은 반대 방식을 쓴다. §17 P3.

동시성 제어는 전부 데이터베이스에 있다. FOR UPDATE SKIP LOCKED(청구), 서버측 토큰 증가, 펜싱 술어, ON CONFLICT DO NOTHING, 복합 기본키. Java 쪽에 락이 없다.

수명주기: OutboxRelayWorker 는 데몬 스레드 1개, setExecuteExistingDelayedTasksAfterShutdownPolicy(false), start() 멱등, stop(deadline) 드레인 후 실패 시 shutdownNow(). 셋 다 근거 주석이 있다(:79-88, :92, :121-122).


8. 설정·기능 플래그·환경 차이

출처 기본 비고
batchSize OutboxProperties 100
leaseDuration 30초 >= publishTimeout × 2 강제
publishTimeout 5초
pollInterval 500ms 백오프의 기준 간격
retentionAfterPublish 3일
maxAttempts 10 청구 술어의 attempts < ?
maxInterval starter :63 1분 OutboxRetryScheduler.standard() 는 5분
maxBatches starter :141 20 하드코딩 실질 무의미 (§12.1(a))
relay owner OutboxRelay.defaultOwner() pid@uuid8 프로세스당 안정
CDC 모드 DebeziumOutboxProfile 어떤 프로퍼티에도 연결 안 됨

maxInterval 이 두 값(1분 / 5분)으로 갈리는 것은 결함이 아니다 — starter 가 명시적으로 넘기고, standard() 는 호출자가 정책을 주지 않은 경우의 기본값이다.


9. 퍼시스턴스/외부 시스템 세부

테이블 2개. messaging_outbox(V1+V2+V4, 최종 34컬럼 + 생성 컬럼 1), messaging_admin_operation(V3, 11컬럼).

인덱스 4개, 전부 부분 인덱스: ix_..._claimable, ix_..._published_at, ix_..._next_attempt, ix_..._tenant_backlog, 그리고 ix_messaging_admin_operation_live.

헤더 직렬화는 손으로 쓴 JSON 이다.

// :604-610
/**
 * <p>Hand-rolled rather than pulled from a JSON library so this module keeps no codec dependency:
 * outbox headers are always flat string pairs, validated by {@code MessageHeaders} before they
 * ever reach here.
 */

이스케이프는 제어문자까지 처리하며 그 이력이 적혀 있다(:630-636). 그러나 역파싱의 종료 판정에 결함이 있다 — §12.1(b), EVD-314 에서 런타임 재현했다.


10. 테스트 레인과 실제 증명 범위

EVD-313: ./gradlew :messaging:messaging-outbox-jdbc-postgresql:test --rerun-tasks76 tests, 0 failures, 0 skipped.

클래스 종류
OutboxPostgresIT 21 컨테이너 (Postgres)
DebeziumOutboxRecordMapperTest 16 단위
OutboxOperationsTest 10 단위 (대역)
OutboxRelayTest 9 단위 (대역)
AdminOperationJournalPostgresIT 7 컨테이너 (Postgres)
OutboxEnvelopeFactoryTest 6 단위
JdbcOutboxTransactionRequirementTest 4 단위
OutboxRelayWorkerTest 3 단위 (스레드)

컨테이너 레인 28건이 실제로 실행되었다skipped="0" 이고 tests>0. docker version 은 client 29.1.3 / server 29.6.1 을 보고하고 /var/run/docker.sock 이 마운트되어 있다(EVD-313).

이는 앞선 리프 문서들이 "컨테이너 필요 — 미실행" 으로 남긴 항목들(messaging-testkit 의 인증 레인 등)이 실행 불가가 아니라 아직 실행하지 않은 것임을 뜻한다. 해당 리프 분석 시 실행한다.

OutboxPostgresIT 가 실제로 증명하는 것 중 강한 것들:

  • theRowAndTheBusinessChangeCommitTogetherOrNotAtAll — 아웃박스의 존재 이유 그 자체.
  • aSupersededRelayCannotOverwriteTheOutcomeOfTheOneThatReplacedIt / twoRelaysClaimingConcurrentlyGetDisjointRowsAndDistinctTokens / anExpiryReclaimKeepsTheMessageIdAndAdvancesTheToken — V2 펜싱의 3대 성질.
  • anAmbiguousRowWaitsForItsBackoffBeforeItIsClaimedAgain / aRowOutOfAttemptsIsNotClaimedAgain / anExhaustedRowIsDistinctFromARejectedOne — 재시도 시계가 행에 있다는 주장.
  • everyCanonicalColumnRoundTripsThroughTheDatabase / theRelayCanSelectOneTenantsBacklogWithoutDecodingAPayload / theStoredRoutingKeyIsTheOneBothRelaysWouldUse / aTenantThatBreaksTheSlugBoundIsRefusedByTheDatabase — V4 의 네 가지 주장.

증명되지 않는 것:

  • 정리 작업이 실제로 나눠 지운다는 것 (§12.1(a)).
  • 역슬래시로 끝나는 헤더 값의 왕복 (§12.1(b)). aHeaderValueWithControlCharactersRoundTrips 는 제어문자만 본다.
  • 배포되는 .properties 가 Java 설정과 일치한다는 것 (§12.4(a)).
  • 두 릴레이 상호배제가 기동에서 강제된다는 것 (§12.1(c)).
  • 구세대 MessageId 기반 전이가 신세대와 같은 행 상태를 남긴다는 것 (§12.3(a)).

11. 빌드/ArchUnit/CI 강제 지점

이 리프 고유의 Gradle 게이트는 없다. 루트 공통 게이트만 적용된다. 컨테이너 IT 가 test 태그에서 제외되지 않는다 — 즉 Docker 가 있는 환경에서는 일반 test 로 함께 돈다. messaging-kafka 의 인증 레인이 별도 태그로 분리된 것(그 리프 문서 §6 참조)과 대비된다.

app-bootstrapMessagingCapabilityRegistryContractTest:61"debezium" 문자열을 능력 목록에 갖고 있다 — 이 리프의 CDC 경로가 플랫폼 능력으로 선언되어 있다는 뜻이다. 그 선언과 §12.1(c)의 미배선 사이의 대조는 §A18 재검증 시 다룬다.


12. 실제 사용 여부와 negative-space probes

12.1 Public surface reachability

(a) [P1] 정리 작업이 무제한 DELETE 를 쏜다 (EVD-311, EVD-294)

OutboxRepository 는 purge 오버로드를 둘 갖고, 구현도 둘 다 있다.

// JdbcOutboxRepository.java:486-518  bounded
// The CTE picks a bounded set of ids with SKIP LOCKED and deletes exactly those. An unbounded
// DELETE holds locks and writes WAL in proportion to the whole backlog, which stalls the relay
// and the business writes behind retention.
WITH expired AS (SELECT message_id FROM messaging_outbox
                 WHERE status='PUBLISHED' AND published_at < ?
                 ORDER BY published_at LIMIT ? FOR UPDATE SKIP LOCKED)
DELETE FROM messaging_outbox o USING expired e WHERE o.message_id = e.message_id

// JdbcOutboxRepository.java:519-533  unbounded
DELETE FROM messaging_outbox WHERE status = 'PUBLISHED' AND published_at < ?

호출자는 무제한 쪽을 부른다.

// OutboxCleanupJob.java:48-55
for (int batch = 0; batch < maxBatches; batch++) {
  int deleted = outbox.purgePublishedBefore(cutoff);      // 무제한
  removed += deleted;
  if (deleted == 0) break;
}

1회차가 전체를 지우고 2회차가 0을 반환해 break 한다. maxBatches=20(starter :141)은 실질적으로 죽은 값이다.

발동 조건 보정(EVD-316). 이 잡은 starter 빈이지만 스케줄되지 않는다. MessagingReliabilityAutoConfiguration 클래스 javadoc(:32-34)이 그렇게 설계했다고 적는다 — "The cleanup jobs are beans but no scheduler is registered for them. Scheduling is the application's decision: a service running several replicas usually wants one of them to run cleanup, and auto-registering a fixed-rate task would have every replica delete the same rows." 따라서 기본 배포에서는 runOnce 가 한 번도 호출되지 않는다. 무제한 DELETE 는 애플리케이션이 그 지시대로 잡을 스케줄하는 순간 발동한다.

테스트가 이것을 가리는 방식이 inbox 쪽과 동일하다.

// OutboxOperationsTest.java:120-134  RecordingRepository
@Override public int purgePublishedBefore(Instant publishedBefore, int limit) {
  return Math.min(purgePublishedBefore(publishedBefore), limit);   // 전부 지우고 숫자만 깎는다
}
@Override public int purgePublishedBefore(Instant publishedBefore) {
  cutoffs.add(publishedBefore);
  return pass < deletions.size() ? deletions.get(pass++) : 0;      // 스크립트
}

cleanupDeletesInBoundedBatchesRatherThanOneLongStatementList.of(1000, 1000, 250) 을 스크립트로 넣고 removed == 2250, cutoffs.size() == 4 를 단언한다. "나눠 지운다" 는 관측이 전적으로 대역이 만든 것이다. 실 DB 테스트(OutboxPostgresIT:202)도 무제한 쪽만 부른다.

(b) [P2] 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다 (EVD-314 — 런타임 재현)

// JdbcOutboxRepository.java:657-664
private static int findClosingQuote(String text, int from) {
  for (int index = from; index < text.length(); index++) {
    if (text.charAt(index) == '"' && text.charAt(index - 1) != '\\') { return index; }
  }
  return text.length();
}

닫는 따옴표 판정이 "바로 앞 글자가 역슬래시가 아니다" 뿐이다. escape 가 값 끝의 역슬래시를 둘로 늘리므로, 닫는 따옴표 앞이 역슬래시가 되어 종료를 놓친다.

컴파일된 클래스에 jshell + 리플렉션으로 private static toJson/fromJson 을 직접 호출해 재현했다(애플리케이션 소스 무수정).

case 3  in={x-a=a\}                json={"x-a":"a\\"}                out={x-a=a\"}                 EQUAL? false
case 4  in={x-a=a\, x-b=second}    json={"x-a":"a\\","x-b":"second"} out={x-a=a\",, :=x-a, a\",=second}  EQUAL? false
case 5  in={x-a=a\b}               json={"x-a":"a\\b"}               out={x-a=a\b}                 EQUAL? true
new HeaderValue("a\") -> OK, value=a\

값이 끝에 역슬래시를 가질 때만 깨지고, 뒤에 헤더가 하나라도 더 있으면 맵 전체가 붕괴한다 — 키 : 와 키 a\", 가 생기고 x-b 는 사라진다. HeaderValue 는 제어문자만 금지하므로(WireSafeText.require) 이 입력은 플랫폼 자신의 검증 타입을 통과한다.

헤더 주입으로는 이어지지 않는다. 어긋남이 키/값 경계를 밀어내므로 예약 이름은 키가 아니라 값이 되고, 쓰기 경로의 MessageHeaders.application(...) 이 애초에 예약 이름을 거절한다. 데이터 손상이지 취약점은 아니다.

(c) CDC 경로 전체가 배선되지 않았다 (EVD-312)

git grep -n "requireExactlyOneRelay|DebeziumOutboxProfile.polling|RelayMode" -- src
  전부 DebeziumOutboxProfile.java 자기 자신 + DebeziumOutboxRecordMapperTest

DebeziumOutboxProfile 클래스 javadoc(:9-13)은 "the incompatibility is therefore enforced at startup instead of documented" 라고 쓴다. 기동 시 requireExactlyOneRelay 를 부르는 코드가 없다. DebeziumOutboxRecordMapper 는 프로덕션에서 생성되지 않는다. 즉 두 릴레이가 동시에 켜지는 구성을 막는 주체가 없고, CDC 모드를 선택할 프로퍼티도 없다.

(d) 세 타입이 starter 밖 배선을 요구한다. JdbcOutboxRepository(src/main 생성 0), OutboxEnvelopeFactory(0), JdbcAdminOperationJournal(0). 애플리케이션이 등록하지 않으면 릴레이 빈은 OutboxRepository 를 주입받지 못한다.

12.2 Conditional sibling comparison

대조군 1 — 배선된 것 vs 안 된 것. OutboxRelayWorker javadoc(:18-21)이 과거 결함을 기록한다: "The relay, its retry scheduler and the attempt budget all existed and nothing ever called runOnce. An outbox whose relay is never driven is the worst shape of all". 그리고 그 수정이 실제로 배선까지 완료되어 있다(MessagingOutboxRelayLifecycle:42 worker.start()). 같은 리프 안에서 requireExactlyOneRelay 는 같은 상태로 남아 있다.

대조군 2 — 커넥션 획득. appendDataSourceUtils, 나머지는 raw dataSource.getConnection(), JdbcAdminOperationJournal 은 전부 DataSourceUtils. §7.

대조군 3 — inbox 와의 대칭. InboxCleanupJob/OutboxCleanupJob 은 같은 형태이며 같은 결함을 갖는다(EVD-294). starter 가 둘 다 maxBatches=20 으로 만든다.

대조군 4 — 컨테이너 레인 정책. 이 리프의 IT 는 test 에 포함되어 함께 돈다. messaging-kafka 의 인증 레인은 태그로 분리되고 Docker 가드도 없다. 두 정책이 공존하는 이유는 각 리프에 설명되어 있다(전자는 skip 가능, 후자는 skip 이 성공으로 보고되면 안 됨).

12.3 Duplicate mechanism sweep

(a) 전이 메서드가 두 세대이며 남기는 행 상태가 다르다.

항목 신세대 (OutboxLease) 구세대 (MessageId)
술어 message_id AND status='IN_FLIGHT' AND lease_owner=? AND lease_token=? message_id
markPublished SET status, published_at, lease_expires_at=NULL, lease_owner=NULL, next_attempt_at=NULL, attempts+1 status, published_at, lease_expires_at=NULL, attempts+1
markAmbiguous SET … lease_owner=NULL, last_failure_code, attempts+1, next_attempt_at=? … last_failure_code, attempts+1
결과 타입 OutboxTransitionResult void
청구 SQL CLAIM (owner/token 기록) LEASE (기록 안 함)

구세대로 PUBLISHED 된 행은 lease_ownernext_attempt_at 이 남는다. 그 컬럼들은 청구 술어와 부분 인덱스가 읽는 값이다. 두 세대 중 어느 것도 @Deprecated 가 아니라는 점은 §A19-MESSAGING-RELIABILITY-API 에 기록되어 있고, 여기서는 상태 차이가 구체적으로 무엇인지가 추가된다.

(b) Debezium 설정이 두 표현으로 존재한다. §12.4(a).

(c) 손으로 쓴 JSON 코덱이 이 리프에도 있다. JdbcOutboxRepository.toJson/fromJson/escape/unescapeBrokerCertificationEvidence(messaging-testkit), InMemoryAdminOperationJournal.key(messaging-admin-runtime)와 같은 계열의 선택이다. 각각 이유가 적혀 있고("이 모듈은 코덱 의존을 두지 않는다"), 각각 다른 방식으로 구현되어 있다. 그중 하나에서 파싱 결함이 나왔다(§12.1(b)).

12.4 Documentation / measured-count drift

(a) [P2] 배포되는 커넥터 설정이 수정 이전 버전이다 (EVD-310)

항목 Java connectorConfiguration debezium/outbox-event-router.properties
event.key routing_key destination
route.topic.replacement topicPrefix + ${routedByValue} ${routedByValue}
event.timestamp (없음) created_at
additional.placement 항목 수 15 4

properties 에 없는 11개: created_at, destination, producer, occurred_at, correlation_id, causation_id, tenant, partition_key, ordering_key, traceparent, tracestate, baggageV4 가 추가한 정경 메타데이터 전부다.

DebeziumOutboxEventRouter javadoc(:21-26)과 V4 주석(:55-63)이 둘 다 "destination 을 키로 쓰면 한 토픽의 모든 메시지가 한 파티션에 몰린다" 를 고쳤다고 말한다. 배포되는 파일에는 그 수정이 없다.

그리고 두 표현을 잇는 것이 없다.

git grep -rn "outbox-event-router" -- src
exit 1  (출력 없음)

Java 쪽은 오히려 의도적으로 견고한 테스트가 지키고 있다.

// DebeziumOutboxRecordMapperTest.java:154-162
void theRoutedKeyIsNotTheTopicName() {
  // Literals, not the class's own constants: comparing a configuration value against the constant
  // that produced it asserts that the router agrees with itself, which it always will.
  assertThat(new DebeziumOutboxEventRouter().connectorConfiguration("prod."))
      .as("keying by destination puts every message on a topic onto one partition")
      .containsEntry("transforms.outbox.table.field.event.key", "routing_key")
      .containsEntry("transforms.outbox.route.by.field", "destination");
}

리터럴 대조까지 하는 테스트가 Java 를 지키고, 운영자가 배포하는 파일은 아무도 지키지 않는다.

(b) aggregateIdAsPartitionKey 는 커넥터에 도달할 수 없다. DebeziumOutboxRecordMapper 는 그 플래그로 분기해 Optional.empty() 를 낼 수 있지만(:70-73), connectorConfiguration(String topicPrefix) 는 프로필을 받지 않고 event.key 를 항상 routing_key 로 고정한다. 기본값(polling()false)에서 모델은 "키 없음" 을 예측하고 실제 커넥터는 키를 붙인다. 이 클래스의 존재 이유가 "Produces what Debezium's Event Router will emit"(:11)인 만큼 무해하지 않다.

(c) 백오프 지터가 복제본을 분산시키지 못한다 (EVD-312)

// OutboxRetryScheduler.java:18-20
/**
 * <p>Jitter is applied deterministically from the attempt count rather than randomly. Several relay
 * instances that all started at deployment time would otherwise synchronise their retries into a
 * thundering herd ...
 */
// :107
long jittered = capped - (capped / 8) * (exponent % 3);

jitteredexponent 만의 함수이고 exponent 는 워커의 unproductivePasses 카운터다. 같은 시각에 배포되어 같은 브로커 장애를 겪는 복제본들은 같은 카운터를 갖게 되므로 같은 backoff 를 계산한다. 지터는 시도 횟수에 따라 값을 바꿀 뿐 인스턴스에 따라 바꾸지 않는다.

(행 단위 백오프 nextAttemptAtnext_attempt_at 컬럼에 기록되므로 이 문제와 무관하다. javadoc 이 말하는 "several relay instances … synchronise their retries" 는 pass 단위 얘기다.)

(d) 선언 의존은 모두 사용된다. 5개 project 의존 중 미사용 0건 — 지금까지 본 messaging 리프 중 처음이다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

SQL 마이그레이션과 javadoc 이 함께 이력을 이룬다. 여덟 개의 "이전에는 이랬다".

위치 기록된 과거 결함
V2:3-14 리스만으로는 stale relay 가 PUBLISHED 위에 AMBIGUOUS 를 덮어썼다
V2:25-27 "V1's CHECK listed five states, so writing the sixth failed at the constraint rather than at review"
V4:6-9 정경 필드가 갈 곳이 없어 유실되거나 msg.* 로 밀반입되었다
V4:56-61 Debezium 키가 destination 이라 한 토픽의 모든 메시지가 한 파티션에 몰렸다
JdbcOutboxRepository:155-162 append(Connection, …) 이 public 이었고 안전한 경로가 "알아야만 하는" 것이었다
JdbcOutboxRepository:205-209 append 가 풀에서 raw 커넥션을 열어 자동 커밋했다 — "a business transaction that rolled back afterwards left the event behind"
JdbcOutboxRepository:630-636 이스케이프가 역슬래시와 따옴표만 처리해 제어문자가 JSONB 를 깨뜨렸다
OutboxRelay:117-123 "The scheduler was built by the auto-configuration and handed to nobody"
OutboxRelayWorker:18-21 "The relay, its retry scheduler and the attempt budget all existed and nothing ever called runOnce"
OutboxEnvelopeFactory:27-37 정경 필드를 빈 값으로 재구성하고 라우팅 키를 헤더 맵에서 읽었다
CLAIM SQL:119-122 AMBIGUOUS 행이 다음 패스에 바로 재청구되어 시도 예산이 아무도 안 읽는 숫자였다

마지막 두 개(OutboxRelay:117-123, OutboxRelayWorker:18-21)가 이 저장소 전체에서 반복되는 결함 계열 — "만들어졌지만 아무도 부르지 않는다" — 을 명시적으로 이름 붙인 유일한 자리다. 그리고 이 리프에서는 그 둘이 실제로 고쳐졌다. §12.1(c)의 requireExactlyOneRelay 만 같은 상태로 남았다.


14. 런타임·터미널 Evidence

ID 파일 내용
EVD-310 evidence/raw/310-debezium-properties-vs-java-drift.txt Java 설정 vs 배포 properties 항목별 대조, 헤더 매핑 15 vs 4, 연결 코드 0건
EVD-311 evidence/raw/311-outbox-cleanup-unbounded-confirmed.txt bounded/unbounded 두 SQL 전문, 호출자, starter 배선, 대역의 스크립트
EVD-312 evidence/raw/312-outbox-assembly-and-jitter.txt 조립 탐침 전수, 릴레이 기동 확인(대조군), CDC 미배선, 지터 분석
EVD-313 evidence/raw/313-messaging-outbox-jdbc-test-lane.txt 76건 통과 + 컨테이너 런타임 가용성 확인
EVD-314 evidence/raw/314-outbox-header-json-roundtrip-corruption.txt jshell 리플렉션 재현 5케이스 + 주입 불가 확인 + HeaderValue 수용 확인

15. 명시적 설계 이유와 추론을 구분한 정리

코드/주석에 명시된 것

  • message_id 를 기본키로 삼은 이유 (V1:3-5).
  • 부분 인덱스인 이유, IN_FLIGHT 를 청구 대상에 넣는 이유 (V1:28-36).
  • 펜싱 토큰이 필요한 이유와 리스 연장이 답이 아닌 이유 (V2:3-14).
  • EXHAUSTED 를 새 상태로 만든 이유 (V2:25-27).
  • 정경 메타데이터를 blob 이 아니라 컬럼으로 둔 이유 (V4:11-14).
  • tenant 제약을 DB 에도 거는 이유 (V4:33-36).
  • routing_key 를 생성 컬럼으로 만든 이유 (V4:55-63).
  • append 가 호출자 커넥션을 쓰는 이유, 그리고 fail-fast 인 이유 (JdbcOutboxRepository:37-46, 221-227).
  • FOR UPDATE SKIP LOCKED 의 이유 (:44-46).
  • 열 목록을 상수로 뽑은 이유 (:64-71).
  • 서버측 토큰 증가의 이유 (:106-111).
  • 재시도 시계를 행에 두는 이유 (CLAIM:119-122, markAmbiguous:79-81).
  • 0행을 STALE_LEASE 로 보고하는 이유 (:392-398).
  • bounded purge 가 필요한 이유 (:164-166) — 정작 호출되지 않는다.
  • 손으로 쓴 JSON 의 이유, 제어문자 이스케이프의 이유 (:604-610, 630-636).
  • 모호를 같은 id 로 재시도하는 이유, at-least-once 상한의 이유 (OutboxRelay:17-27).
  • attempts + 1 로 예산을 판정하는 이유 (:179-181).
  • EXHAUSTED 로 주차하는 이유 (:186-188).
  • default -> 분기의 이유 (:213-215).
  • 리스가 발행 타임아웃의 2배여야 하는 이유 (OutboxProperties:10-14).
  • pass 백오프와 row 백오프가 서로를 대체하지 않는 이유 (OutboxRetryScheduler:11-16).
  • 시프트를 쓰는 이유 (:102-103).
  • 데몬 스레드·자기 스케줄링·드레인 종료의 이유 (OutboxRelayWorker:23-30, 79-88, 99-106).
  • 패스 실패가 루프를 끝내면 안 되는 이유 (:184-187).
  • 정리가 PUBLISHED 만 지우는 이유 (OutboxCleanupJob:10-14).
  • 봉투 재구성 시 부재 값 처리의 이유 (OutboxEnvelopeFactory:87-92).
  • 예약 이름을 예외 없이 거절하는 이유 (:33-37).
  • 저널이 아웃박스 옆에 사는 이유 (build.gradle:9-13, JdbcAdminOperationJournal:24-28).
  • DB 제약이 경쟁을 결판내는 이유 (:26-28).
  • 읽기와 인수 사이 경쟁을 거절하는 이유 (:181-184).
  • 두 릴레이 동시 실행이 불가능해야 하는 이유 (DebeziumOutboxProfile:9-13, properties :3-5).
  • CDC 모델을 Java 로 만든 이유 (DebeziumOutboxRecordMapper:11-18).
  • schema subject 만 헤더가 없는 이유 (DebeziumOutboxEventRouter:28-33).
  • 부재를 빈 문자열로 쓰지 않는 이유 (:105-107).
  • 컨테이너 테스트가 필요한 이유 (build.gradle:16-17).

추론 (근거는 있으나 문서에 없음)

  • withConnectionDataSourceUtils 를 쓰지 않는 것은 릴레이가 비즈니스 트랜잭션에 합류하면 안 되기 때문으로 보인다. 주석은 없고, 같은 리프의 저널은 반대로 한다.
  • .properties 가 갱신되지 않은 것은 누락으로 보인다 — Java 쪽 수정에 붙은 근거가 파일 쪽에도 그대로 적용되기 때문. 의도적 분기라는 표시는 없다.
  • maxBatches=20 하드코딩이 프로퍼티가 아닌 이유는 알 수 없다.
  • 구세대 MessageId 오버로드가 남아 있는 이유, 그리고 그것이 lease_owner 를 지우지 않는 것이 의도인지 누락인지.
  • aggregateIdAsPartitionKey 가 커넥터 설정에 전달되지 않는 것이 의도인지 누락인지.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • production 13파일 + SQL 4 + properties 1 전부 본문 확인.
  • 테스트 76건 전건 통과, 컨테이너 IT 28건이 실제로 실행됨 (EVD-313).
  • 이 환경에서 Docker 사용 가능 (client 29.1.3 / server 29.6.1, 소켓 마운트).
  • 정리 작업이 무제한 DELETE 를 쏜다는 것 — 두 SQL·호출자·starter 배선·대역 전부 확인 (EVD-311).
  • 역슬래시 종결 헤더 값의 왕복 손상 — jshell 리플렉션으로 런타임 재현 (EVD-314).
  • Debezium 설정 두 표현의 항목별 차이와 연결 코드 0건 (EVD-310).
  • 조립 탐침 전수, 릴레이 기동 확인, CDC 미배선 (EVD-312).
  • 구·신 전이 메서드의 SET 절 차이.

확인하지 못한 것

  • 테스트 8파일을 축자 통독하지 않았다. 76개 메서드 이름 전수와 판정에 필요한 구간(대역 구현, purge/Debezium/이스케이프 단언)만 읽었다. 커버리지 원장에 STRUCTURAL_ONLY 로 기록했다.
  • §12.1(a)와 (c)의 결과를 실제 배포에서 관측하지 않았다. (a)는 SQL·호출자·배선으로, (c)는 호출부 부재로 도출했다.
  • 실제 Debezium 커넥터를 띄워 properties 의 동작을 확인하지 않았다. 두 설정의 차이는 텍스트 대조로 확인했다.
  • §12.4(c)의 지터 동기화를 다중 인스턴스로 재현하지 않았다. 함수가 exponent 만의 함수라는 것은 코드로 확인했다.
  • 구세대 전이 메서드가 실제로 호출되는 배포가 있는지 — 이 저장소에는 없다.

17. 손볼 것

P1 — 정리 작업이 무제한 DELETE 를 쏘고, 그것을 막는 오버로드는 호출되지 않는다

OutboxCleanupJob:50InboxCleanupJob:56 이 무제한 오버로드를 부른다. bounded 오버로드(purgePublishedBefore(Instant, int) / purgeProcessedBefore(Instant, int))는 두 포트에 선언되고 두 구현에 구현되어 있으며 호출부가 0건이다(EVD-294, EVD-311).

두 잡 모두 starter 빈이지만 스케줄러는 등록되지 않으며, 그것은 의도된 설계다(EVD-316). 즉 기본 배포에서는 아무 일도 일어나지 않고, 애플리케이션이 문서 지시대로 잡을 스케줄하는 순간 무제한 DELETE 가 발동한다. 잠재 결함이지 상시 결함이 아니다.

bounded 구현의 주석이 결과를 명시한다: "An unbounded DELETE holds locks and writes WAL in proportion to the whole backlog, which stalls the relay and the business writes behind retention." 3일치 백로그가 쌓인 테이블에서 이것은 릴레이 정지와 비즈니스 쓰기 정체를 뜻한다. 두 리프 모두 runtime_memberships: ["app-bootstrap"] 이고 두 잡 모두 starter 빈이다.

수정은 한 줄이다 — purgePublishedBefore(cutoff, batchLimit). maxBatches 가 그제서야 의미를 갖는다. 배치 크기는 새 파라미터가 필요하고, OutboxProperties.batchSize(100)를 재사용하거나 별도 값을 둔다.

그리고 회귀 테스트가 성립하려면 RecordingRepository 를 고쳐야 한다. 현재 대역의 bounded 구현은 Math.min(unbounded(), limit) 로, 전부 지우고 숫자만 깎는다. 실제 저장소를 흉내 내려면 보유 행 목록을 갖고 limit 만큼만 제거해야 한다.

P2 — 배포되는 Debezium 설정이 수정 이전 버전이다

src/main/resources/debezium/outbox-event-router.propertiesevent.key=destination 을 유지하고 있다. 같은 저장소의 Java(DebeziumOutboxEventRouter), V4 마이그레이션 주석, 그리고 전용 테스트(theRoutedKeyIsNotTheTopicName)가 모두 그것이 결함이라고 말한다 — "keying by destination puts every message on a topic onto one partition".

추가로 헤더 매핑이 15개 중 4개뿐이라, 이 파일로 배포한 CDC 는 tenant·correlation·causation·producer·trace·partition/ordering key 를 전부 잃는다. V4 가 존재하는 이유가 그 유실을 막는 것이다.

두 가지가 필요하다.

  1. properties 를 Java 설정에서 생성하거나, 최소한 둘을 대조하는 테스트를 둔다. DebeziumOutboxEventRouter.connectorConfiguration("") 의 항목이 파일에 모두 있는지 확인하는 테스트면 충분하다. 지금은 두 표현을 잇는 코드가 한 줄도 없다.
  2. aggregateIdAsPartitionKeyconnectorConfiguration 에 전달하거나, 전달할 수 없다면 DebeziumOutboxRecordMapper 에서 그 분기를 제거한다. 지금은 모델이 커넥터가 하지 않을 일을 예측한다.
P2 — 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다

findClosingQuote(:657-664)가 이스케이프된 역슬래시를 고려하지 않는다. 값이 역슬래시로 끝나면 파서가 종료 지점을 놓치고, 뒤에 헤더가 더 있으면 맵 전체가 붕괴한다(EVD-314, 런타임 재현).

HeaderValue 는 제어문자만 금지하므로 이 입력은 플랫폼 검증을 통과한다. 헤더 주입으로 이어지지는 않는다 — 예약 이름은 키가 아니라 값이 되고, 쓰기 경로가 예약 이름을 이미 거절한다.

수정: 종료 판정을 "앞의 연속된 역슬래시 개수가 짝수" 로 바꾸거나, 인덱스를 앞에서부터 스캔하며 이스케이프 상태를 추적한다. 후자가 unescape 와 대칭이라 낫다.

테스트는 OutboxPostgresIT.aHeaderValueWithControlCharactersRoundTrips 옆에 역슬래시 종결 케이스를 추가하면 된다 — 실 DB 왕복까지 확인할 수 있다.

P2 — 두 릴레이 상호배제가 기동에서 강제되지 않는다

DebeziumOutboxProfile.requireExactlyOneRelay(...) 는 프로덕션 호출부가 0건이다. 클래스 javadoc 은 "the incompatibility is therefore enforced at startup instead of documented" 라고 쓴다. properties 파일도 같은 경고를 반복한다("Enable this OR the in-process polling relay, never both").

같은 리프에 정확히 이 형태를 고친 선례가 있다 — OutboxRelayWorker 가 "nothing ever called runOnce" 를 고치고 MessagingOutboxRelayLifecycle 로 배선까지 마쳤다. 같은 방식으로 MessagingReliabilityAutoConfiguration 에 프로필 빈과 InitializingBean 검사를 두면 된다.

배선하려면 CDC 모드를 선택할 프로퍼티도 필요하다 — 지금은 DebeziumOutboxProfile 을 만드는 설정 경로 자체가 없다.

P3 — 구세대 전이 메서드가 신세대와 다른 행 상태를 남긴다

markPublished(MessageId, Instant)lease_ownernext_attempt_at 을 지우지 않는다. markPublished(OutboxLease, Instant) 는 지운다. markAmbiguous/markFailed 도 같다. 두 컬럼은 청구 술어와 부분 인덱스가 읽는 값이다.

이 저장소에 구세대를 부르는 프로덕션 코드는 없다. 그러나 포트에 남아 있고 @Deprecated 도 아니므로, 외부 구현이나 향후 코드가 부를 수 있다. 최소한 @Deprecated 와 "신세대를 쓰라"는 문장이 필요하고, 더 나은 것은 제거다.

P3 — 백오프 지터가 인스턴스를 분산시키지 못한다

jittered = capped - (capped/8) * (exponent % 3)exponent 만의 함수다. 같은 상태의 복제본들은 같은 값을 계산한다. javadoc 이 약속하는 "thundering herd 방지" 가 성립하지 않는다.

OutboxRelay 가 이미 defaultOwner() 로 프로세스별 안정 식별자를 만든다(pid@uuid8). 그것의 해시를 지터에 섞으면 결정성(같은 프로세스에서 재현 가능)을 유지하면서 인스턴스 간 위상차가 생긴다. javadoc 이 난수를 거부한 이유("a random source would make the schedule impossible to test")도 그대로 지켜진다.

P3 — 커넥션 획득 방식이 리프 안에서 갈린다

JdbcOutboxRepository.appendDataSourceUtils, 나머지는 raw dataSource.getConnection(), JdbcAdminOperationJournal 은 전부 DataSourceUtils. 릴레이 연산이 비즈니스 트랜잭션에 합류하면 안 된다는 판단은 타당하지만 어디에도 적혀 있지 않고, 같은 리프의 저널이 반대로 한다.

withConnection 에 한 문장 — "릴레이 연산은 호출자 트랜잭션에 합류하지 않는다" — 을 붙이면 append 의 상세한 주석과 짝이 맞는다. 저널이 DataSourceUtils 를 쓰는 것이 의도인지도 확인이 필요하다.

P3 — maxBatches 가 하드코딩이고 현재는 의미가 없다

starter 가 20 을 박아 넣는다(:141, :170). P1 을 고치기 전에는 이 값이 아무 일도 하지 않고, 고친 뒤에는 배치 크기와 함께 조정 대상이 된다. OutboxProperties/InboxRetentionPolicy 로 옮기는 것이 맞다.

확인된 설계(문제 아님)
  • append 의 트랜잭션 3중 검사. 활성/쓰기 가능/같은 DataSource 바인딩. 세 번째가 특히 드물고 정확하다.
  • message_id 를 기본키로. 어떤 코드 경로도 새 id 로 같은 행을 발행할 수 없다.
  • 펜싱 토큰을 서버측 한 문장에서 증가. 두 릴레이가 같은 번호를 받을 수 없다.
  • 종결 쓰기의 owner+token 술어, 그리고 0행을 삼키지 않는 것. stale 은 중복 발행의 가시화된 형태다.
  • 재시도 시계를 행에 기록. 프로세스 메모리의 백오프는 재시작에 잊히고 복제본마다 따로 계산된다.
  • EXHAUSTED 를 별도 상태로. AMBIGUOUS 로 두면 대시보드에서 건강한 백로그와 구별되지 않는다.
  • spent = attempts + 1 로 예산 판정. 마지막 시도가 두 번 소비되지 않는다.
  • default -> 에서 크게 실패하기. 새 completion 이 조용히 IN_FLIGHT 를 남기지 않는다.
  • 리스 ≥ 발행 타임아웃 × 2 를 생성자가 강제. 그리고 기본값이 자기 규칙을 만족하는지 테스트가 있다.
  • 정경 메타데이터를 컬럼으로. 운영자 질문이 SELECT 가 된다.
  • tenant 제약을 DB 에도. 애플리케이션 밖 INSERT 를 막는다.
  • routing_key 생성 컬럼. 두 릴레이의 폴백 규칙을 한 곳에 고정한다 (Java 쪽 한정으로).
  • 봉투 재구성 시 예약 이름을 예외 없이 거절. 라우팅 키가 컬럼이 된 뒤 규칙이 단순해졌다.
  • 부재를 빈 문자열로 쓰지 않기 (CDC 헤더, 봉투 양쪽).
  • 패스 실패가 루프를 끝내지 않게. 스케줄된 작업의 예외는 이후 모든 패스를 취소한다.
  • 드레인 종료. 인터럽트는 크래시와 같은 정체를 만든다.
  • 정리가 PUBLISHED 만 대상으로. AMBIGUOUS·FAILED 는 사건 중 가장 필요한 행이다.
  • 저널을 아웃박스 옆에 두고 DB 제약으로 경쟁을 결판내기. check-then-act 는 두 복제본을 모두 통과시킨다.
  • 읽기와 인수 사이의 경쟁을 RETURNING 0행으로 거절.
  • 컨테이너 IT 를 test 에 포함. 이 리프의 주장은 실제 DB 로만 결판난다.
  • 제어문자 이스케이프. (역슬래시 종결 케이스는 §17 P2.)

Source anchors

src/messaging/messaging-outbox-jdbc-postgresql/build.gradle:1-24
src/config/architecture/modules.json                        (messaging-outbox-jdbc-postgresql 항목)

main/resources/db/migration/messaging/V1__messaging_outbox.sql:1-41
main/resources/db/migration/messaging/V2__messaging_outbox_lease_fencing.sql:1-39
main/resources/db/migration/messaging/V3__messaging_admin_operation_journal.sql:1-37
main/resources/db/migration/messaging/V4__messaging_outbox_canonical_metadata.sql:1-66
main/resources/debezium/outbox-event-router.properties:1-43

main/…/JdbcOutboxRepository.java:37-47,50-62,64-78,80-104,106-142,151-153,155-200,203-218,221-246,249-267,270-306,308-318,319-339,340-350,352-370,371-379,381-389,391-421,424-432,434-444,446-454,456-469,471-484,486-518,519-533,535-545,547-591,593-601,603-628,630-655,657-664,666-700,702-722
main/…/OutboxRelay.java:17-28,40-44,66-72,90-99,117-123,145-221,223-230
main/…/OutboxRelayWorker.java:15-31,34-35,53-90,92-97,99-125,127-130,132-157,159-174,176-193
main/…/OutboxRetryScheduler.java:8-21,28-43,45-61,63-80,82-85,87-90,92-109,111-119,121-133
main/…/OutboxProperties.java:7-22,31-32,34-59,61-74
main/…/OutboxCleanupJob.java:7-15,22-36,38-57
main/…/OutboxRelayReport.java:3-26,30-49
main/…/OutboxEnvelopeFactory.java:20-38,43-50,52-104,106-123
main/…/JdbcAdminOperationJournal.java:22-33,36-72,79-82,84-120,122-140,142-160,162-192,217-235,237-245,247-262,263-271,273-283,285-318,320-324
main/…/DebeziumOutboxProfile.java:6-18,22-28,30-36,38-45,47-66
main/…/DebeziumOutboxEventRouter.java:10-34,37-47,49-85,87-136,138-150
main/…/DebeziumOutboxRecordMapper.java:10-27,30-32,34-43,45-68,70-78
main/…/DebeziumMappedRecord.java:8-23,25-34,36-53

test/…/OutboxPostgresIT.java  (메서드 인벤토리 21건; 195-202, 503-521, 538-560 본문 확인)
test/…/OutboxOperationsTest.java:105-190 (RecordingRepository + cleanup 3건 본문 확인)
test/…/DebeziumOutboxRecordMapperTest.java:150-200 (본문 확인), 58-148 (메서드명)
test/…/OutboxRelayTest.java / OutboxRelayWorkerTest.java / OutboxEnvelopeFactoryTest.java /
test/…/JdbcOutboxTransactionRequirementTest.java / AdminOperationJournalPostgresIT.java  (메서드 인벤토리)

src/messaging/messaging-spring-boot-starter/.../MessagingReliabilityAutoConfiguration.java:63,89,109,140-141,169-170
src/messaging/messaging-spring-boot-starter/.../MessagingOutboxRelayLifecycle.java:42
src/messaging/messaging-core-api/.../header/HeaderValue.java:5-25
src/messaging/messaging-inbox-jdbc-postgresql/.../InboxCleanupJob.java:56
src/app-bootstrap/src/test/.../MessagingCapabilityRegistryContractTest.java:61

A19-MESSAGING-POLICY. messaging-policy

분석 중에는 messaging/MESSAGING-POLICY.md 파일이었다. 880줄.

messaging-policy 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-policy SSOT owner: messaging-policy integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-policy
  • canonical state analysisFile: §A19-MESSAGING-POLICY
  • source path: src/messaging/messaging-policy
  • registry allowed_dependencies: ["messaging-core-api", "messaging-schema-api"]
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 26
production LOC 1,738
패키지 1 (dev.caskeleton.messaging.policy)
test 파일 4
test 메서드(실행 확인) 42
외부(비프로젝트) 의존성 0

26개 타입을 관심사로 나누면 다섯이다.

타입
목적지 정의 (8) DestinationProfile · PhysicalDestination · SchemaPolicy · ProducerPolicy · ConsumerPolicy · PayloadPolicy · DeadLetterPolicy · CapabilityTier
시작 검증 (1) DestinationProfileValidator
발행 관문 (3) MessagingAdmissionController · PayloadLimitGuard · InFlightLimiter
재시도 판단 (8) RetryPolicy · RetryMode · OrderingImpact · RetryContext · RetryDecision · RetryDecisionEngine · DefaultRetryDecisionEngine · BackoffCalculator
DLQ 조정 (6) DeadLetterOrchestrator · DeadLetterEnvelopeFactory · DeadLetterMetadata · DeadLetterResult · SourceSettlement · FailureDescriptorDefaults(package-private)

다섯 축의 배선 상태가 서로 다르다. 목적지 정의·시작 검증·발행 관문은 출하 컨텍스트에서 실제로 실행되고, 재시도 판단과 DLQ 조정은 bean으로 생성되지만 주입되는 곳이 없다(§12.1).

Coverage ledger
scope/file group count disposition reason
src/main/java/** (26) 26 FULL_READ 전 파일 본문 확인
src/test/java/** (4) 4 FULL_READ 전 파일 본문 및 단언 확인
build.gradle 1 FULL_READ 6줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

이 leaf는 **"이 목적지는 무엇을 약속하는가"**를 소유한다. 브로커를 만지지 않고 벤더 의존성이 0이며, 대신 브로커 어댑터가 따라야 할 판단을 미리 계산한다.

경계 규칙 하나가 leaf 전체를 관통한다: 모순은 부팅 실패여야 한다.

// DestinationProfileValidator.java:20-24
 * <p>Every rule here exists because the alternative is a production surprise. A profile that asks
 * for ordered delivery and configures a reordering retry does not fail on the happy path; it fails
 * the first time a message is retried, months later, in a way that looks like a data bug rather
 * than a configuration one. Making the contradiction a boot failure moves that discovery to the
 * deploy that introduced it.

두 번째 경계는 물리 주소의 격리다.

// PhysicalDestination.java:9-11
 * <p>Held here and nowhere else. Once a topic name reaches application code the logical destination
 * stops being a boundary, and swapping the broker under a service becomes a code change instead of
 * a configuration change.

messaging-core-apiDestinationName:/를 정규식으로 막고(그쪽 §4), 이 leaf가 물리 주소를 독점한다. 두 leaf가 같은 경계를 양쪽에서 지킨다.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), messaging-schema-api(api). 둘 다 api인 이유는 DestinationProfileDeliveryGuarantee·OrderingScope·DestinationKind·DestinationName(core-api)와 SchemaCompatibility(schema-api)를 필드로 갖기 때문이다.

나가는 것: messaging-transport-spi, messaging-runtime-core, messaging-kafka, messaging-kafka-share-experimental, messaging-rabbit, messaging-outbox-jdbc-postgresql, messaging-admin-api, messaging-admin-runtime, messaging-pulsar-experimental, messaging-nats-experimental, messaging-spring-cloud-stream-bridge, messaging-spring-boot-starter, messaging-testkit.

실제 배선 지점 넷(전부 messaging-spring-boot-starter/MessagingCoreAutoConfiguration):

지점 라인 상태
new DestinationProfileValidator().validateAll(registered) 134 실행됨 — 시작 시 전체 registry 검증
DestinationProfileValidator bean 145146 생성
MessagingAdmissionController bean 407417 생성 + DefaultMessagePublisher·MessagingEndpoint·MessagingShutdownLifecycle이 주입받음
RetryDecisionEngine bean 167169 생성, 주입처 없음(§12.1)
DeadLetterOrchestrator bean 179181 생성, 주입처 없음(§12.1)

이 leaf 자체는 Spring 주석을 갖지 않는다 — bean 정의는 전부 starter 쪽에 있다.


3. 패키지/컴포넌트 지도

[목적지 정의]
DestinationProfile ─┬─ PhysicalDestination   (topic/exchange/routingKey/queue/subject/stream)
                    ├─ SchemaPolicy          (codec, compatibility, 닫힌 messageTypes)
                    ├─ ProducerPolicy        (confirmation, timeout, mandatoryRouting, idempotent)
                    ├─ ConsumerPolicy        (group, concurrency, maxInFlightPerUnit, prefetch, timeout, manual)
                    ├─ RetryPolicy           (mode, maxAttempts, backoff, orderingImpact, 카테고리 오버라이드)
                    ├─ DeadLetterPolicy      (enabled, destination, maxRedriveCount)
                    ├─ PayloadPolicy         (maxBytes, claimCheckThreshold)
                    └─ CapabilityTier        (M1/M2/M3)

[시작 검증]  DestinationProfileValidator
             ├─ validate(profile)      : 프로파일 내부 모순 15가지
             └─ validateAll(profiles)  : 중복 이름 + retry/DLQ 그래프 사이클

[발행 관문]  MessagingAdmissionController
             ├─ PayloadLimitGuard ── PayloadPolicy
             └─ InFlightLimiter   (Semaphore, fair)

[재시도 판단] RetryContext ─→ RetryDecisionEngine ─→ RetryDecision (sealed 5)
                                     ↑
                          DefaultRetryDecisionEngine ── BackoffCalculator

[DLQ 조정]   DeadLetterOrchestrator ─┬─ DeadLetterEnvelopeFactory ── DeadLetterMetadata
                                     └─ SourceSettlement → DeadLetterResult

4. 계약·불변식·상태 모델

4.1 DestinationProfileValidator.validate — 15가지 모순 거절

프로파일 하나에 대해 순서대로 검사한다.

# 거절 조건
1 retry.orderingImpact == PRESERVE && retry.reorders() 정책이 자기 자신과 모순
2 isOrdered() && retry.orderingImpact == ALLOW_REORDER 순서 목적지가 재정렬 재시도를 허용
3 payload.maxBytes > 8,388,608 절대 상한 초과
4 claimCheckThreshold > payload.maxBytes 오프로드 문턱이 상한보다 큼
5 DLQ가 자기 자신을 가리킴 무한 루프
6 retry 목적지가 자기 자신을 가리킴 무한 루프
7 orderingScope == KEY && !keyResolverConfigured 키 기반 순서인데 키 추출기 없음
8 tier == M1 && consumer.manualSettlement M1이 수동 정산을 쓰면 정산 순서가 앱으로 새 나감
9 AT_LEAST_ONCE && producer.confirmation == NONE 확인 없는 at-least-once는 보장이 아님
10 production && topologyAutoCreate 운영에서 앱이 토폴로지를 만듦
11 orderingScope == DESTINATION && consumer.concurrency > 1 목적지 전체 순서는 동시성 1을 요구
12 isOrdered() && maxInFlightPerOrderingUnit > 1 순서 단위 안 동시 처리
13 physical.isEmpty() 물리 주소 없음
14 retry.mode == NONE && maxAttempts > 1 모드와 횟수 모순
15 retry.mode == RETRY_DESTINATION && retryDestination.isEmpty() 목적지 없는 재시도 목적지 모드
16 maxAttempts > 1 && mode != NONE && !deadLetter.enabled 재시도하는데 소진 후 갈 곳 없음

11번과 12번이 짝이다 — 전자는 목적지 수준 동시성, 후자는 순서 단위 안 동시성. 둘 다 있어야 "순서 보장"이 실제로 성립한다.

4.2 validateAll — 두 종류의 간선을 하나의 그래프로

이 leaf에서 가장 정교한 판단이다.

// :131-136
// One graph carrying both edge kinds, not two walks.
//
// Walking retry and dead-letter separately misses a cycle that alternates between them: A's
// retry points at B and B's dead letter points back at A. Neither single-edge walk revisits a
// node, both pass, and a poison message loops between the two destinations forever. The label
// is kept per edge so the reported path still says which kind each hop was.

Edge enum이 RETRYDEAD_LETTER 둘을 갖고, walk가 두 간선을 동시에 따라간다.

onPath가 전역 방문 집합이 아니라 현재 경로다.

// :164-169
 * <p>{@code onPath} is the current walk rather than everything ever seen, so a diamond  two
 * destinations that both forward to a third  is not mistaken for a loop.
walk(nextProfile, byName, new LinkedHashSet<>(onPath), branch);

각 분기마다 new LinkedHashSet<>(onPath)로 복사하므로 형제 분기가 서로의 방문 기록을 오염시키지 않는다. 다이아몬드(A→C, B→C)는 사이클이 아니고, 그것을 사이클로 판정하면 정상 구성이 부팅에 실패한다.

테스트가 두 경우를 각각 붙든다 — aMixedEdgeCycleIsRejected(retry/DLQ 교대 사이클 거절)와 aSharedDeadLetterIsNotACycle(다이아몬드 허용).

미등록 목적지도 여기서 잡힌다 — anUnregisteredRetryDestinationIsRejected.

비용 주의. 매 분기마다 onPathpath를 복사하므로 시간·공간이 경로 수에 지수적이다. 목적지 수가 수십 개인 정상 구성에서는 문제가 없지만, 이 성질이 어디에도 기록되지 않았다 — §17의 P3.

4.3 MessagingAdmissionController — 순서가 계약이다
// :13-16
 * <p>Order matters and is fixed here rather than left to each adapter: the payload limit is checked
 * <em>before</em> a permit is taken. An oversized message can never succeed, so letting it occupy a
 * scarce in-flight permit while it is being rejected would let a stream of bad messages starve the
 * good ones.

admit의 실제 순서:

  1. payloadGuard.checkPayload → 초과면 MessageTooLargeException
  2. acceptingNewWork 확인 → 종료 중이면 MessageBackpressureException("SHUTTING_DOWN")
  3. reserve(destination) — 목적지별 CAS 루프 → 초과면 DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED
  4. limiter.tryAcquire() — 프로세스 전역 semaphore, 유한 대기 → 실패면 목적지 슬롯 반납 후 IN_FLIGHT_LIMIT_EXCEEDED

두 개의 천장이 있는 이유도 명시돼 있다.

// :23-26
 * <p>Two ceilings, because one is not enough. The per-destination ceiling stops a single slow
 * downstream from consuming every permit in the process, and the process-wide ceiling stops the sum
 * of well-behaved destinations from exhausting memory  without it, adding a destination silently
 * raises what the process can be holding at once.

거절이 모호하지 않은 것이 설계의 핵심이다 — "Both refusals happen before transmission, so neither is ambiguous — the caller may resubmit under the same message id without risking a duplicate." messaging-core-api의 3상태 발행 결과와 직접 연결된다.

세 가지 누수 방지가 코드에 있다.

} catch (InterruptedException interrupted) {
  // The destination slot was taken a moment ago and no publish will use it, so it goes back
  // here: a slot leaked per interruption shrinks the destination's ceiling until it is zero.
  release(destination);
public void complete(String destination) {
  if (!release(destination)) {
    // A completion for a destination that holds nothing: either it names the wrong destination or
    // it is a second completion for the same publish. Returning the process permit anyway frees
    // one nobody took, and the process-wide ceiling then reads below what is really in flight and
    // admits more work than the process can carry.
    return;
  }
  limiter.release();
}
// release():195-197
// Drop the entry at zero, atomically, so the map does not accumulate one counter per
// destination ever published to for the life of the process.
perDestination.computeIfPresent(destination, (key, value) -> value.get() == 0 ? null : value);

세 번째는 장기 실행 누수 방지다 — 목적지 이름이 동적이면(예: 테넌트별) 맵이 무한히 자란다.

InFlightLimiterfair semaphore를 쓰는 이유도 적혀 있다 — "an unfair semaphore lets a late arrival barge ahead of a caller that has already been waiting, which turns a bounded wait into an unbounded one for the unlucky."

release()availablePermits() < limit를 확인하고 반납한다 — "an unbalanced release would raise the ceiling silently and the limiter would stop limiting anything."

4.4 DefaultRetryDecisionEngine — 고정된 판단 순서
// :10-15
 * <p>The order is fixed and evaluated top to bottom. Retryability is checked before the attempt
 * budget so that a deserialization failure is parked on its first delivery instead of being
 * replayed three more times against a payload that cannot change. The ordering-preserving strategy
 * is checked before the re-publishing one so that an ordered destination can never fall through to
 * a strategy that reorders it, even if both are technically configured.

실제 순서:

# 조건 결정
1 !isRetryable(...) park(context) — DLQ가 있으면 DeadLetter, AT_MOST_ONCE이고 DLQ 없으면 Reject, 그 외 DeadLetter
2 attempt >= maxAttempts DeadLetter
3 orderingImpact == PRESERVE && isOrdered() && capabilities.orderedStream() PauseAndRetry(delay)
4 mode == PAUSE_PARTITION PauseAndRetry(delay)
5 mode == RETRY_DESTINATION && ALLOW_REORDER && retryDestination.isPresent() PublishToRetryDestination
6 mode == INLINE || BLOCKING RetryInline(delay)
7 mode == BROKER_DELAYED && capabilities.delayedDelivery() PublishToRetryDestination
8 (그 외) DeadLetter

capability가 입력이다.

// RetryContext.java:11-13
 * <p>Capabilities are an input rather than an assumption: the same policy resolves to
 * pause-and-retry on a partitioned Kafka topic and to a retry destination on a queue that cannot
 * pause, and the engine must not pick a strategy the adapter cannot actually carry out.

3번과 7번이 그것을 쓴다 — orderedStream()이 false면 pause 전략이 선택되지 않고, delayedDelivery()가 false면 BROKER_DELAYED가 8번으로 떨어져 DLQ가 된다. 조용한 성능 저하 대신 명시적 파킹이다.

isRetryable의 3단 판정:

if (policy.nonRetryableCategories().contains(category)) return false;   // 명시적 제외 최우선
if (policy.retryableCategories().contains(category)) return true;       // 명시적 허용
return descriptorRetryable && FailureDescriptorDefaults.retryable(category);  // 둘 다 만족해야

마지막 줄이 AND다 — descriptor가 retryable이라 해도 카테고리 기본값이 false면 재시도하지 않는다. RetryPolicy 생성자가 두 집합의 교집합을 거절하므로(§4.5) 1·2번이 동시에 참일 수 없다.

FailureDescriptorDefaults는 package-private 위임자다 — "kept in one place so policy and engine cannot disagree". 실제로는 FailureDescriptor.defaultRetryable(core-api)를 그대로 부른다. 한 줄 짜리 간접층이지만 정책 쪽에서 기본값을 바꿔야 할 때 바꿀 지점을 명시한다.

4.5 RetryPolicy — 기본값이 "재시도 없음"
// :13-15
 * <p>Automatic retry is opt-in. The default for an ordinary destination is zero attempts, because a
 * retry that reorders a stream, multiplies a non-idempotent side effect, or hammers a throttled
 * downstream is worse than a visible failure.

none()mode=NONE, maxAttempts=1, delays=ZERO, multiplier=1.0, jitter=false, orderingImpact=PRESERVE, 두 집합 비어 있음이다.

생성자 검증 여섯:

  • maxAttempts >= 1 (첫 전달 포함)
  • 두 지연 음수 아님
  • maxDelay >= initialDelay
  • multiplier >= 1.0
  • 두 카테고리 집합을 Set.copyOf로 복사
  • 두 집합의 교집합 거절 — "a failure category cannot be both retryable and non-retryable"

reorders()RETRY_DESTINATION || BROKER_DELAYED다 — 이 둘만 메시지를 원래 순서 단위 밖으로 옮긴다. RetryMode javadoc이 같은 사실을 반대편에서 적는다.

4.6 BackoffCalculator — full jitter
// :11-14
 * <p>The delay is {@code min(maxDelay, initialDelay * multiplier^(attempt-1))}. Full jitter then
 * picks uniformly from {@code [0, delay]} rather than shaving a small percentage off. That matters
 * when a downstream recovers: without jitter every consumer that failed in the same second retries
 * in the same second, and the recovery is immediately undone by the retry storm.

randomFractionDoubleSupplier로 주입 가능해서 테스트가 결정론적이다. 테스트가 두 각도를 본다 — backoffGrowsExponentiallyAndIsCappedByMaxDelayfullJitterSpreadsRetriesAcrossTheWholeWindow.

capped <= 0이면 Duration.ZERO를 반환하므로 initialDelay=0인 정책에서 곱셈이 무의미해지는 경우를 방어한다.

4.7 DeadLetterOrchestrator — 하나의 불변식
// :21-29
 * <p>This ordering is the single invariant that stops dead lettering from becoming data loss. If
 * the source were acknowledged first, a failed dead letter publish would leave no copy of the
 * message anywhere: the broker has released it and the dead letter destination never received it.
 * So the source stays unsettled on anything other than a confirmed publish, including an ambiguous
 * one, and the message is redelivered instead of disappearing.
 *
 * <p>An ambiguous dead letter publish therefore produces a duplicate rather than a loss. That is
 * the intended trade: the dead letter destination is read by humans who can spot a duplicate, and
 * it is the only side of the trade that is recoverable.

구현이 그 문장 그대로다.

.thenCompose(result -> {
  if (result.completion() != PublishCompletion.CONFIRMED) {
    return CompletableFuture.completedFuture(new DeadLetterResult(result, false));
  }
  return settleAfterConfirmation(result, settlement);
});

CONFIRMED가 아니면 — REJECTEDAMBIGUOUS든 — 원본을 정산하지 않는다. messaging-core-api의 3상태가 여기서 실제 분기가 된다.

SourceSettlement이 콜백으로 주입되는 이유도 적혀 있다 — "so that the ordering constraint … lives in one place instead of being re-implemented by every adapter."

4.8 DeadLetterEnvelopeFactory — 예약 헤더 6개, payload 불변
// :16-21
 * <p>The payload and the logical {@code messageId} are carried through untouched. That is what
 * makes a redrive a genuine replay rather than a new message: an Inbox downstream still recognises
 * it, and an operator can correlate the dead letter with the original publish.
 *
 * <p>Failure context is written into reserved headers, never into the payload, so redriving does
 * not require unwrapping a platform-specific structure.

쓰는 헤더: FAILURE_CATEGORY, FAILURE_CODE, ORIGIN_DESTINATION, RETRY_ATTEMPT, FIRST_FAILURE_AT, LAST_FAILURE_AT. 전부 ReservedHeaders의 상수를 쓴다(리터럴 아님).

MessageHeaders.platform(headers)를 쓴다 — 예약 이름을 쓸 수 있는 factory다(messaging-core-api §4.8). 이것이 core-api의 두 factory 분리가 실제로 필요한 이유를 보여주는 유일한 production 사용처다.

여섯 헤더 중 RETRY_ATTEMPT·FIRST_FAILURE_AT·LAST_FAILURE_AT·FAILURE_CATEGORY·FAILURE_CODE·ORIGIN_DESTINATION은 전부 CanonicalEnvelopeHeaders가 "platform bookkeeping"으로 분류한 8개에 속한다 — 봉투 필드가 없어서 헤더로만 이동할 수 있는 것들이다. 두 leaf의 분류가 정확히 맞물린다.

4.9 DeadLetterMetadata — 일부러 작다
// :11-13
 * <p>Deliberately small. A dead letter destination is read by operators, exported to tickets, and
 * often retained far longer than the source topic, so it holds a category, a code, and timing  not
 * a stack trace, not the exception message, and not the original headers.

messaging-core-apiFailureDescriptor javadoc("a DLQ is read by more people than the log is")과 같은 판단을 다른 층에서 반복한다.

한 가지 관측. DeadLetterOrchestratorDeadLetterMetadata를 만들 때 firstFailureAtlastFailureAt같은 값(delivery.metadata().receivedAt())을 넣는다.

Instant failedAt = delivery.metadata().receivedAt();
DeadLetterMetadata metadata = new DeadLetterMetadata(..., failedAt, failedAt);

즉 두 필드가 구분되어 선언됐지만 현재 유일한 생산 경로에서는 항상 같다. 첫 실패 시각을 이전 시도에서 이어받는 코드가 없다 — §17의 P3.


5. 주요 실행 경로

시작: MessagingCoreAutoConfiguration:134validateAll(registered) → 프로파일별 15검사 + 중복 이름 + 사이클 그래프 → 실패 시 IllegalArgumentException으로 부팅 중단

발행: DefaultMessagePublisheradmission.admit(destination, bytes) → 크기 → 종료 여부 → 목적지 슬롯 → 프로세스 permit → (발행) → admission.complete(destination)

재시도 판단: RetryContext(profile, deliveryMetadata, failure, capabilities, ...)engine.decide(...)RetryDecision 5종 중 하나 — 이 경로는 출하 컨텍스트에서 호출되지 않는다(§12.1)

DLQ: orchestrator.deadLetter(profile, delivery, failure, settlement) → 헤더 6개 추가 → 발행 → CONFIRMED면 원본 정산 — 이 경로도 호출되지 않는다(§12.1)


6. 실패 경로와 복구/번역

코드 예외 위치 조건
PAYLOAD_LIMIT_EXCEEDED MessageTooLargeException PayloadLimitGuard 목적지 상한 초과
BATCH_COUNT_EXCEEDED MessageTooLargeException PayloadLimitGuard 배치 항목 수 초과
BATCH_BYTES_EXCEEDED MessageTooLargeException PayloadLimitGuard 배치 총 바이트 초과
SHUTTING_DOWN MessageBackpressureException MessagingAdmissionController 종료 중
DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED MessageBackpressureException 같음 목적지 천장
IN_FLIGHT_LIMIT_EXCEEDED MessageBackpressureException 같음 프로세스 천장
ADMISSION_INTERRUPTED MessageBackpressureException 같음 대기 중 인터럽트
DEAD_LETTER_NOT_CONFIGURED MessagingConfigurationException DeadLetterOrchestrator DLQ 미설정 목적지를 DLQ하려 함

배치 상한이 두 축인 이유가 적혀 있다.

// PayloadLimitGuard.java:16-18
 * <p>Batches are limited by count <em>and</em> bytes. A count limit alone lets a handful of large
 * messages exceed the broker's frame; a byte limit alone lets a huge number of tiny messages exceed
 * its request timeout.

checkBatch가 각 항목에 대해 checkPayload도 부르므로 개별 상한 · 개수 상한 · 총합 상한 셋이 함께 적용된다.

프로파일 검증 실패는 IllegalArgumentException이다 — MessagingException 계층 밖이다. 시작 시점의 구성 오류이지 메시지 실패가 아니므로 일관적이다. 다만 MessagingConfigurationException("Raised at startup wherever possible")이 존재하는데 쓰이지 않는다 — §17의 P3.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

동시성 지점은 MessagingAdmissionControllerInFlightLimiter 둘이다.

지점 도구 보호
perDestination ConcurrentHashMap + computeIfAbsent 목적지 카운터 생성
목적지 카운터 증가 AtomicInteger CAS 루프 천장 초과 방지
목적지 카운터 감소 getAndUpdate + 0 clamp 음수 방지
맵 항목 제거 computeIfPresent (원자) 0일 때만 제거, 누수 방지
acceptingNewWork volatile boolean 종료 플래그 가시성
permit Semaphore(limit, true)fair 유한 대기 보장
permit 반납 availablePermits() < limit 확인 천장 상승 방지

reserve의 CAS 루프는 AtomicInteger.updateAndGet으로 쓸 수 있었지만 조건부 실패(return false)가 필요해서 직접 루프를 돈다.

release미세한 경합이 있다. getAndUpdate로 감소한 뒤 computeIfPresent로 0인 항목을 제거하는데, 그 사이에 다른 스레드가 computeIfAbsent로 같은 키를 만들고 증가시킬 수 있다. 그러면 computeIfPresent의 람다가 value.get() == 0을 보지 못해 제거하지 않는다 — 안전한 방향의 경합이다(누수가 아니라 제거 실패). 반대 순서였다면 살아 있는 카운터를 지울 수 있었다.

DefaultRetryDecisionEngine·BackoffCalculator·DeadLetterOrchestrator·DeadLetterEnvelopeFactory·DestinationProfileValidator는 전부 상태가 없거나 불변이다. BackoffCalculator의 기본 생성자가 ThreadLocalRandom을 쓰므로 스레드 안전하다.

수명주기 참여는 stopAcceptingNewWork() 하나이고, MessagingShutdownLifecycle(starter)이 종료 1단계에서 부른다(messaging-transport-spi §12.1 참조).


8. 설정·기능 플래그·환경 차이

설정 파일 없음. 상수와 기본값:

상수/기본값 위치
PayloadPolicy.DEFAULT_MAX_BYTES 1,048,576 PayloadPolicy.java:17 (public)
PayloadPolicy.HARD_MAX_BYTES 8,388,608 :20 (public)
ProducerPolicy.defaults() REPLICATION_OR_PERSISTENCE_ACK, 5초, mandatoryRouting, idempotent :34-37
ConsumerPolicy.defaults(group) concurrency 1, maxInFlightPerUnit 1, prefetch 16, timeout 30초, manual false :52-54
RetryPolicy.none() mode NONE, 1회, 지연 0, PRESERVE :115-125
DeadLetterPolicy.disabled() / .to(dest) maxRedrive 0 / 1 :32-44

모든 기본값이 보수적이다 — 재시도 없음, 동시성 1, 순서 보존, 확인 최대, DLQ 비활성. 켜는 것이 명시적 선택이다.

PayloadPolicy.HARD_MAX_BYTES = 8 MiB의 근거도 적혀 있다 — "Raising a broker's frame limit to carry large payloads trades a bounded, testable failure for an unbounded one: it degrades broker memory, replication latency, and consumer recovery all at once."

PayloadPolicy.DEFAULT_MAX_BYTES는 이 저장소에서 1 MiB 상한을 선언하는 다섯 곳 중 하나이고 정책 축의 자연스러운 주인이다. 그런데 starter는 이것 대신 JacksonMessageCodec.DEFAULT_MAX_BYTES를 참조한다 — §A19-MESSAGING-SCHEMA-JSON §17이 소유한다.


9. 퍼시스턴스/외부 시스템 세부

없다. 브로커·DB·파일시스템을 만지지 않는다. ThreadLocalRandom(jitter)과 Semaphore가 유일한 런타임 자원이다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-policy:test. BUILD SUCCESSFUL, 42 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
DestinationProfileValidatorTest 13 순서/페이로드/DLQ 자기참조/키 리졸버/M1 수동정산/확인/토폴로지/DLQ 필요, retry↔DLQ 교대 사이클 거절, 다이아몬드 허용, 미등록 목적지 거절 실제 부팅에서 이 검증이 호출되는지(→ starter가 부른다, §2)
MessagingAdmissionControllerTest 13 permit 점유/반납, 초과 시 큐잉 대신 거절, backpressure가 retryable, 초과 payload가 permit을 안 먹음, 종료 시 기존 permit 유지, 불균형 반납이 천장을 못 올림, 한 목적지가 전부 못 먹음, 거절이 슬롯을 안 남김, 완료가 둘 다 반납, 미지 목적지 완료가 permit을 안 품, 이중 완료, 배치 두 축, 대기 후 승인 실제 부하에서의 공정성
RetryDecisionEngineTest 10 역직렬화 실패 즉시 파킹, 인증/구성 실패 미재시도, 순서 Kafka는 pause, 소진은 DLQ, 비순서 재시도목적지 재발행, blocking은 inline, 지수 증가와 상한, full jitter 분포, 프로파일 오버라이드, at-most-once DLQ 없으면 discard 이 엔진이 production에서 호출되는지
DeadLetterOrchestratorTest 6 확인 후에만 원본 정산, 모호하면 미정산, 거절되면 미정산, 헤더 부착 이 orchestrator가 production에서 호출되는지

두 축의 증명 성격이 다르다. 검증기와 관문은 배선까지 확인되지만(§2), 재시도 엔진과 DLQ 조정자는 로직만 증명되고 배선은 §12.1이 부정한다. 테스트가 통과한다는 것이 그 코드가 실행된다는 뜻이 아닌 전형적인 예다.

MessagingAdmissionControllerTestas(...) 문구들이 특히 구체적이다 — "a slot leaked per refusal shrinks the destination's ceiling until it is zero", "a permit nobody took cannot be given back; doing so makes the ceiling fiction". 각 테스트가 어떤 이전 결함을 붙들고 있는지 이름 자체가 말한다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api","messaging-schema-api"]
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 벤더 의존성 0
부팅 검증 MessagingCoreAutoConfiguration:134validateAll을 호출 — 이 leaf의 규칙이 실제로 부팅을 막는 유일한 지점
ArchUnit 전용 규칙 없음

§4.1의 15가지 규칙은 ArchUnit이 아니라 런타임 시작 시점에 강제된다. verifyCleanArchitectureDependencies가 빌드 타임에 도는 것과 대비된다. 잘못된 프로파일은 컴파일되고, 부팅에서 막힌다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/281-messaging-policy-retry-engine-unwired.txt.

방법 주의. 이 절의 조립 판정은 new ([a-zA-Z0-9_.]+\.)?<Type>\s*\( 패턴으로 재확인한 것이다. 처음에 new <Type>(로만 검색해 오탐을 냈다 — 이 저장소는 new dev.caskeleton.messaging.runtime.TransportMessagingRuntime(처럼 정규화된 이름으로 생성하는 곳이 있고, 그 패턴은 그것을 놓친다. 아래 결과는 전부 수정된 패턴의 것이다.

12.1 Public surface reachability

leaf 밖 참조가 0인 것은 둘이고 성격이 다르다.

타입 leaf 밖 판정
DeadLetterEnvelopeFactory 0 내부 협력자DeadLetterOrchestrator가 쓴다. 문제 아님
DeadLetterMetadata 0 같음

나머지 24개는 전부 외부 참조가 있다. DestinationProfile 43파일, RetryDecision 23, RetryContext 18, SchemaPolicy 17, PayloadPolicy 15, PhysicalDestination 13.

참조 수는 이 leaf에서 오해를 낳는다. 참조가 있어도 실행되지 않을 수 있고, 여기가 정확히 그렇다.

(a) RetryDecisionEngine bean은 만들어지고 아무 데도 주입되지 않는다

// MessagingCoreAutoConfiguration.java:165-169
@Bean
@ConditionalOnMissingBean
public RetryDecisionEngine retryDecisionEngine() {
  return new DefaultRetryDecisionEngine(new BackoffCalculator());
}

이 타입을 받는 코드는 저장소 전체에서 하나다 — KafkaRetryExecutor의 필드와 생성자 인자(KafkaRetryExecutor.java:32,46).

그리고 KafkaRetryExecutor한 번도 생성되지 않는다.

## D. is each of those dependents ever constructed?
KafkaRetryExecutor           NEVER CONSTRUCTED

즉 5개 @Bean 설정 클래스가 만드는 51개 bean 중 어느 것도 RetryDecisionEngine을 인자로 받지 않는다. bean은 매 시작마다 생성되고 컨텍스트에 앉아 있다.

(b) DeadLetterOrchestrator bean도 같다

// :177-181
@Bean
@ConditionalOnMissingBean
public DeadLetterOrchestrator deadLetterOrchestrator(MessagePublisher publisher) {
  return new DeadLetterOrchestrator(publisher);
}

이 타입을 받는 production 코드는 둘 — KafkaDeadLetterPublisher(:29)와 RabbitDeadLetterPublisher(:47). 둘 다 NEVER CONSTRUCTED.

(c) 왜 그런가 — 소비 경로 전체에 production 조립이 없다

## F. control: the consume path is constructed only in tests
KafkaConsumerRegistrar         src/main=0  src/test=4
RabbitConsumerRegistrar        src/main=0  src/test=1
KafkaBatchConsumerRegistrar    src/main=0  src/test=0
RabbitBatchConsumerRegistrar   src/main=0  src/test=1
DefaultDeliveryProcessor       src/main=0  src/test=1

대조군으로 발행 경로를 같은 패턴으로 확인하면 전부 production에서 생성된다.

## E. control: the publish path IS constructed in production
DefaultMessagePublisher    MessagingCoreAutoConfiguration.java:446
TransportMessagingRuntime  MessagingCoreAutoConfiguration.java:476
DefaultRetryDecisionEngine MessagingCoreAutoConfiguration.java:168
DeadLetterOrchestrator     MessagingCoreAutoConfiguration.java:180

즉 출하 컨텍스트는 발행할 수 있고 소비할 수 없다. 재시도와 DLQ는 소비 경로에만 존재하는 개념이므로, 이 leaf의 두 축이 배선되지 않은 것은 그 결과다.

이 사실은 §A19-MESSAGING-CORE-API §12.1이 관측한 것 — MessageHandler<T>의 저장소 참조 0 — 에 조립 쪽 설명을 준다. 핸들러를 받을 소비자 런타임이 조립되지 않으므로 핸들러 계약에 소비자가 없다.

(d) RetryDecision을 실제로 실행하는 코드는 하나뿐이다

## G. every file that acts on a RetryDecision variant
messaging-kafka/.../KafkaRetryExecutor.java          (생성되지 않음)
messaging-policy/.../DefaultRetryDecisionEngine.java (생산자)
messaging-policy/.../RetryDecision.java              (선언)
messaging-policy/.../RetryDecisionEngineTest.java    (테스트)

messaging-rabbit은 production 코드에서 RetryDecision·RetryDecisionEngine·BackoffCalculator·RetryPolicy를 전혀 참조하지 않는다(테스트 fixture 한 곳 제외). Rabbit에는 RabbitRetryQueueTopology가 있는데 그것은 토폴로지 서술(TTL 큐 + DLX)이고 RetryDecision을 소비하지 않는다. Pulsar·NATS도 0이다.

즉 브로커 중립 재시도 엔진의 실행자가 저장소에 한 브로커 분량만 있고, 그마저 조립되지 않았다.

(e) 배선된 축은 확실히 배선됐다

  • DestinationProfileValidatorMessagingCoreAutoConfiguration:134에서 validateAll(registered) 호출. 부팅을 실제로 막는다.
  • MessagingAdmissionControllerDefaultMessagePublisher(발행 관문)·MessagingEndpoint(관측)·MessagingShutdownLifecycle(종료 1단계) 셋이 주입받는다.
  • PayloadLimitGuard·InFlightLimiter·PayloadPolicy → admission controller 안에서 실행된다.

한계. 정적 git grep이다. 리플렉션·ObjectProvider 지연 조회·@Autowired 필드 주입은 덮지 못한다. 다만 이 저장소의 messaging 자동설정은 전부 생성자 주입 @Bean 메서드이고(51개 전수 확인), ObjectProviderMessageContractsMessagingTransport 두 곳에만 쓰인다.

12.2 Conditional sibling comparison

이 leaf에는 bean이 없다. 그러나 starter 쪽 sibling 비교가 결정적이다.

MessagingCoreAutoConfiguration의 27개 @Bean 중 이 leaf의 타입을 만드는 것은 셋이고, 조건이 전부 같다(@ConditionalOnMissingBean).

bean 조건 주입처
DestinationProfileValidator @ConditionalOnMissingBean (직접 호출도 있음, :134)
MessagingAdmissionController @ConditionalOnMissingBean 3곳
RetryDecisionEngine @ConditionalOnMissingBean 0곳
DeadLetterOrchestrator @ConditionalOnMissingBean 0곳

조건은 같고 결과가 다르다. 활성화 비대칭이 아니라 소비 비대칭이다 — 넷 다 똑같이 만들어지고 둘만 쓰인다. @ConditionalOnMissingBean은 "이미 있으면 만들지 마라"를 뜻할 뿐 "쓰이는지"를 말하지 않는다.

12.3 Duplicate mechanism sweep

(a) 재시도 메커니즘이 둘이고, 정교한 쪽이 배선되지 않았다

messaging-policy messaging-runtime-core
구현 DefaultRetryDecisionEngine DefaultDeliveryProcessor
입력 RetryContext(프로파일 + 전달 메타 + 실패 + capability) HandleResult
재시도 판단 6개 모드, 8단 우선순위 Retry → 무조건 requeue
지연 BackoffCalculator — 지수 + full jitter + 상한 생성자로 받은 고정 retryDelay
시도 횟수 attempt >= maxAttempts 확인 확인하지 않음
순서 인식 orderingImpact·isOrdered()·capabilities 없음
DLQ 5개 결정 중 하나 DeadLetter → 발행 후 확인되면 ack
production 조립 없음 없음(테스트만)

둘 다 조립되지 않았으므로 오늘 경쟁하지 않는다. 그러나 소비 경로를 배선하려는 사람은 두 개의 서로 다른 재시도 의미론 중 하나를 골라야 하고, 어느 쪽이 정본인지 코드가 말하지 않는다. DefaultDeliveryProcessor의 javadoc은 자기가 "the platform decides when and in what order the settlement happens"를 실현한다고 말하고, DefaultRetryDecisionEngine의 javadoc은 자기 순서가 "fixed and evaluated top to bottom"이라고 말한다.

(b) DLQ 경로가 둘

messaging-policy messaging-runtime-core
구현 DeadLetterOrchestrator DefaultDeliveryProcessorDeadLetterPublisher 함수형 인터페이스
순서 보장 확인 후 정산 (명시) 확인 후 ack, 미확인이면 requeue (명시)
헤더 6개 예약 헤더 부착 부착하지 않음
결과 DeadLetterResult(publishResult, sourceSettled) SettlementResult

같은 불변식(확인 전 정산 금지)을 두 곳이 각자 구현한다. 그리고 한쪽만 실패 컨텍스트를 헤더에 남긴다DefaultDeliveryProcessor 경로로 DLQ된 메시지는 왜 거기 있는지 알 수 없다.

(c) 1 MiB 상한PayloadPolicy.DEFAULT_MAX_BYTES가 이 저장소 다섯 곳 중 정책 축의 주인인데 starter가 참조하지 않는다. §A19-MESSAGING-SCHEMA-JSON §17이 소유한다.

(d) 프로파일 검증기가 브로커별로 또 있다

RabbitProfileValidator, KafkaProfileValidator, KafkaTransactionProfileValidator가 각 어댑터 leaf에 있고 starter가 bean으로 만든다. 이들은 브로커 고유 제약(exchange/queue 조합, 트랜잭션 설정)을 보므로 DestinationProfileValidator의 브로커 중립 규칙과 책임이 다르다. 중복이 아니라 계층이다. 다만 호출 순서가 어디에도 명시되지 않았다 — 중립 검증이 먼저인지 브로커 검증이 먼저인지는 starter leaf가 답한다.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
DestinationProfileValidator javadoc: 모순은 부팅 실패 :134에서 validateAll 호출 확인 일치
MessagingAdmissionController javadoc: "The single gate every publish passes" DefaultMessagePublisher가 주입받아 호출 일치
PhysicalDestination javadoc: 물리 주소를 여기서만 보관 leaf 밖 13파일이 참조하나 전부 PhysicalDestination 타입 경유 일치
RetryPolicy javadoc: 자동 재시도는 opt-in none()maxAttempts=1, mode=NONE 일치
InFlightLimiter javadoc: "Section 40.3 of the design specifies…" 그 설계 문서를 이 저장소에서 찾지 못함 미확인 — 아래 참조
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift)

InFlightLimiter의 "Section 40.3"이 가리키는 문서를 찾지 못했다. docs/messaging/ 아래 10개 파일과 docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md에 절 번호 40.3이 없다. 저장소 밖 설계 문서이거나 이전 버전의 흔적이다. 인용된 문구("bounded wait, then MessageBackpressureException")는 코드와 일치하므로 내용 drift는 아니고, 참조가 해소되지 않는다는 것이 관측이다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

이 leaf의 주석은 이전 결함보다 왜 이 형태여야 하는가를 더 많이 적는다. 그중 이전 상태를 직접 서술하는 것은 셋이다.

위치 이전 상태 그것이 만든 실패
validateAll 주석 retry 그래프와 DLQ 그래프를 따로 순회 A의 retry가 B를, B의 DLQ가 A를 가리키는 교대 사이클을 둘 다 통과시킴 → poison 메시지가 두 목적지 사이를 영원히 순환
admitInterruptedException 주석 인터럽트 시 목적지 슬롯 미반납 인터럽트마다 슬롯이 새서 목적지 천장이 0까지 줄어듦
complete 주석 미보유 목적지에도 프로세스 permit 반납 아무도 안 가져간 permit을 돌려줘 전역 천장이 실제 in-flight보다 낮게 읽힘 → 감당 못 할 만큼 승인
release 주석 0인 카운터를 맵에 잔류 발행한 적 있는 모든 목적지의 카운터가 프로세스 수명 동안 누적
InFlightLimiter.release 주석 불균형 반납 허용 천장이 조용히 올라가 limiter가 아무것도 제한하지 않음

세 번째와 다섯 번째가 같은 형태다 — 반납이 획득보다 많으면 제한이 사라진다. messaging-transport-spiGracefulShutdownCoordinator.endWork clamp와 DefaultMessagingRuntimeRegistry의 "정확히 한 번 close"도 같은 계열이고, 그 leaf §13이 소유한다. 저장소 전체에서 반복되는 주제다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-281 command evidence/raw/281-messaging-policy-retry-engine-unwired.txt 26개 타입 참조 수, 두 bean의 선언, 그 두 타입을 받는 코드 전수, 해당 dependent가 NEVER CONSTRUCTED, 발행 경로 대조군, 소비 경로 src/main=0, RetryDecision 실행자 목록, 호출되는 시작 게이트 정적 git grep. 리플렉션·지연 조회 미포함. 정규화된 생성자 이름을 포함하는 패턴으로 재실행한 결과
EVD-282 command ./gradlew :messaging:messaging-policy:test --rerun-tasks BUILD SUCCESSFUL, 42 / 0 / 0 순수 단위. 브로커·Spring 컨텍스트 없음

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 모순을 부팅 실패로 옮기는 이유 — DestinationProfileValidator javadoc
  • 두 간선을 한 그래프로 순회하는 이유와 다이아몬드 오탐 방지 — validateAll/walk 주석
  • payload 검사가 permit 획득보다 먼저인 이유 — MessagingAdmissionController javadoc
  • 천장이 둘인 이유 — 같은 javadoc
  • 거절이 모호하지 않은 이유 — 같은 javadoc
  • 세 가지 누수 방지 각각의 이유 — 세 개의 인라인 주석
  • fair semaphore와 불균형 반납 방지 — InFlightLimiter 주석
  • 재시도 판단 순서가 고정된 이유 — DefaultRetryDecisionEngine javadoc
  • capability가 입력인 이유 — RetryContext javadoc
  • 자동 재시도가 opt-in인 이유 — RetryPolicy javadoc
  • full jitter를 쓰는 이유 — BackoffCalculator javadoc
  • DLQ 발행 후 정산 순서와 그 trade — DeadLetterOrchestrator javadoc
  • DLQ 메타데이터를 작게 두는 이유 — DeadLetterMetadata javadoc
  • 물리 주소를 이 leaf에 가두는 이유 — PhysicalDestination javadoc
  • Pulsar 구독명·NATS 스트림이 주소의 일부인 이유 — 두 factory javadoc

추론

  • 재시도 엔진과 DLQ 조정자가 미배선인 것은 소비 경로 전체에 조립이 없기 때문이다 → 추론. 조립 부재는 관측이고 인과는 추론이다. 커밋 메시지나 ADR에 소비 경로를 나중으로 미룬 기록이 없다.
  • firstFailureAtlastFailureAt을 같은 값으로 채우는 것이 임시인지 → 미상.
  • 브로커별 검증기와 중립 검증기의 호출 순서 → 미상(starter leaf가 소유).

관측했으나 원인을 모름

  • InFlightLimiter javadoc이 인용하는 "Section 40.3"의 출처
  • MessagingConfigurationException이 존재하는데 프로파일 검증이 IllegalArgumentException을 쓰는 이유

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 26개 타입 1,738줄 전문의 계약과 불변식
  • 42개 테스트가 통과하고 무엇을 단언하는지
  • 다섯 축 중 셋(목적지 정의·시작 검증·발행 관문)이 출하 컨텍스트에서 실제로 실행된다는 것과 그 정확한 배선 지점
  • 두 축(재시도 판단·DLQ 조정)이 bean으로 생성되고 주입처가 0이라는 것 — 그리고 그 이유가 소비 경로 전체의 조립 부재라는 것
  • RetryDecision을 실행하는 코드가 저장소에 하나뿐이며 그것이 생성되지 않는다는 것
  • 재시도와 DLQ 각각에 대해 두 개의 서로 다른 구현이 존재한다는 것

확인하지 못한 것

  • 소비 경로를 배선할 계획이 있는지. 저장소 안에 답이 없다. 두 재시도 구현 중 어느 쪽이 정본인지도 이 미지수에 걸린다.
  • 실제 부팅에서 validateAll이 어떤 프로파일 집합을 받는지 — ValidatedDestinationRegistry가 무엇을 채우는지는 starter leaf가 소유한다.
  • walk의 지수적 복사 비용이 실제 구성에서 문제가 되는 규모. 목적지 수가 큰 배포를 관측하지 못했다.
  • InFlightLimiter의 fair semaphore가 실제 부하에서 주는 처리량 손실.
  • "Section 40.3"이 가리키는 문서.

17. 손볼 것

P2 — 재시도 엔진과 DLQ 조정자가 bean으로 만들어지고 주입되는 곳이 없다
  • 사실. MessagingCoreAutoConfigurationRetryDecisionEngine(:167)과 DeadLetterOrchestrator(:179)를 @Bean @ConditionalOnMissingBean으로 만든다. 두 타입을 받는 production 코드는 각각 KafkaRetryExecutorKafkaDeadLetterPublisher/RabbitDeadLetterPublisher뿐이고, 셋 다 저장소 어디에서도 생성되지 않는다. 같은 설정의 51개 bean 중 두 타입을 인자로 받는 @Bean 메서드가 없다.
  • 근거. evidence/raw/281 §B·§C·§D.
  • 왜 문제인가. 컨텍스트에 두 bean이 앉아 있고 MessagingAutoConfigurationTest류의 hasSingleBean 검사는 통과한다 — 즉 bean 존재 검사가 배선을 증명하지 않는다. 그리고 이 leaf가 가장 공들인 두 축(6개 재시도 모드·8단 판단 순서·full jitter·capability 인식, DLQ 발행-후-정산 불변식·예약 헤더 6개)이 실행되지 않는다. 42개 테스트 중 16개가 이 두 축을 검증한다.
  • 확인 방법. git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?KafkaRetryExecutor\s*\(' -- src → 매치 없음. evidence/raw/281 §D 재실행.
  • 후보. (a) 소비 경로를 조립한다(§17 다음 항목과 같은 작업). (b) 배선되기 전까지 두 bean을 만들지 않는다 — @ConditionalOnBean으로 실제 소비자에 매단다. (c) 미완임을 support-matrix.md에 표시한다.
  • 다음 단계. CASE 후보. 재현이 정적이고 결론이 닫힌다. "bean이 있다"와 "배선됐다"의 구분이 그대로 REFERENCE 후보이기도 하다.
P2 — 출하 컨텍스트가 발행은 하고 소비는 하지 못한다
  • 사실. KafkaConsumerRegistrar·RabbitConsumerRegistrar·KafkaBatchConsumerRegistrar·RabbitBatchConsumerRegistrar·DefaultDeliveryProcessor·KafkaRetryExecutor·KafkaDeadLetterPublisher·RabbitDeadLetterPublisher가 전부 src/main 생성 0이다. 대조군인 발행 경로(DefaultMessagePublisher·TransportMessagingRuntime)는 MessagingCoreAutoConfiguration:446,476에서 생성된다.
  • 근거. evidence/raw/281 §E·§F.
  • 왜 문제인가. messaging-policy의 두 축이 미배선인 근본 원인이고, §A19-MESSAGING-CORE-API §12.1이 관측한 MessageHandler<T> 참조 0의 조립 쪽 설명이다. 그리고 docs/messaging/support-matrix.md의 브로커 등급표가 소비 측 보장(순서·정산·재시도)을 서술하는데, 그 보장을 수행할 코드가 조립되지 않는다.
  • 확인 방법. evidence/raw/281 §F 재실행.
  • 후보. 소비자 등록을 자동설정에 추가하거나, 소비 경로가 파생 프로젝트의 조립 책임임을 문서화한다.
  • 다음 단계. 이 leaf가 아니라 cross-scope 또는 messaging-spring-boot-starter leaf가 소유해야 한다. 여기서는 관측과 교차 참조만 남긴다. OPEN QUESTION 후보(소비 경로 조립이 미완인가, 의도적 확장점인가).
P3 — 재시도와 DLQ 각각에 두 개의 구현이 있고 정본이 표시되지 않았다
  • 사실. 재시도: DefaultRetryDecisionEngine(6모드·백오프·순서 인식) vs DefaultDeliveryProcessor(고정 지연·시도 횟수 미확인). DLQ: DeadLetterOrchestrator(예약 헤더 6개 부착) vs DefaultDeliveryProcessor.DeadLetterPublisher(헤더 없음). 둘 다 조립되지 않았다.
  • 근거. §12.3(a)(b). DefaultDeliveryProcessor.java:38-99.
  • 왜 문제인가. 오늘 경쟁하지 않지만, 소비 경로를 배선하는 사람이 둘 중 하나를 고르게 되고 코드가 어느 쪽이 정본인지 말하지 않는다. 두 javadoc이 각각 자기가 플랫폼 규칙의 구현이라고 서술한다. 그리고 선택 결과가 다르다 — DefaultDeliveryProcessor 경로로 DLQ된 메시지에는 실패 카테고리·코드·원본 목적지·시도 횟수가 붙지 않는다.
  • 확인 방법. 두 클래스의 javadoc과 분기 대조.
  • 후보. DefaultDeliveryProcessorRetryDecisionEngineDeadLetterOrchestrator를 위임받도록 합치거나, 한쪽을 제거한다.
  • 다음 단계. CASE 후보(같은 책임의 두 구현이 서로를 모른다). messaging-runtime-core leaf SSOT와 공동 소유.
P3 — DLQ 메타데이터의 두 시각이 항상 같다
  • 사실. DeadLetterMetadatafirstFailureAtlastFailureAt을 별도 필드로 선언하는데, 유일한 생산 지점인 DeadLetterOrchestrator:89-97이 둘 다 delivery.metadata().receivedAt()으로 채운다.
  • 근거. 해당 라인.
  • 왜 문제인가. 두 헤더(msg.first-failure-at, msg.last-failure-at)가 DLQ 메시지에 붙는데 항상 같은 값이다. 운영자가 "이 메시지가 얼마나 오래 실패해 왔는가"를 헤더에서 알 수 없다. ReservedHeaders가 두 이름을 따로 정의한 목적이 실현되지 않는다.
  • 확인 방법. DeadLetterOrchestrator.java:89 확인.
  • 후보. 이전 시도의 msg.first-failure-at 헤더가 있으면 그것을 이어받는다.
  • 다음 단계. CASE 후보. 단, §17 첫 항목대로 이 코드는 실행되지 않으므로 오늘의 사고가 아니다.
P3 — 사이클 검사가 경로마다 집합을 복사한다
  • 사실. walk가 각 분기마다 new LinkedHashSet<>(onPath)new ArrayList<>(path)를 만든다. 비용이 경로 수에 비례하고, 경로 수는 분기 계수에 지수적이다.
  • 근거. DestinationProfileValidator.java:196-198.
  • 왜 문제인가. 정상 구성(목적지 수십 개, 목적지당 간선 0–2개)에서는 무해하다. 다만 이 성질이 어디에도 기록되지 않았고, validateAll부팅 경로다. 목적지가 수백 개인 배포에서 부팅이 느려지면 원인을 찾기 어렵다.
  • 확인 방법. 코드 검토. 목적지 수를 늘려가며 validateAll 시간을 측정.
  • 후보. 방문 상태를 색칠(white/gray/black)로 바꾸면 복사 없이 O(V+E)가 된다.
  • 다음 단계. REFERENCE 후보(부팅 경로의 알고리즘 복잡도는 문서화한다).
P3 — 프로파일 검증 실패가 플랫폼 예외 계층 밖이다
  • 사실. DestinationProfileValidator의 16개 거절이 전부 IllegalArgumentException이다. MessagingConfigurationException이 존재하고 그 javadoc이 "Raised at startup wherever possible"이라고 적는다.
  • 근거. DestinationProfileValidator 전문, MessagingConfigurationException javadoc.
  • 왜 문제인가. 부팅 실패이므로 실무 영향은 낮다. 다만 FailureDescriptor가 없어 코드·카테고리가 붙지 않고, 같은 leaf의 DeadLetterOrchestratorMessagingConfigurationException("DEAD_LETTER_NOT_CONFIGURED")을 쓴다 — 같은 leaf 안에서 구성 오류를 두 방식으로 보고한다.
  • 확인 방법. 두 클래스의 throw 문 대조.
  • 후보. 검증 실패를 MessagingConfigurationException으로 통일하고 규칙별 안정 코드를 준다.
  • 다음 단계. REFERENCE 후보(구성 오류는 한 예외 타입과 안정 코드로 보고한다).
P3 — javadoc이 해소되지 않는 설계 문서를 인용한다
  • 사실. InFlightLimiter javadoc이 "Section 40.3 of the design specifies 'bounded wait, then MessageBackpressureException'"이라고 적는다. 그 절 번호를 가진 문서를 이 저장소에서 찾지 못했다.
  • 근거. InFlightLimiter.java:11-13. docs/messaging/*.md 10개와 계획 문서에 절 40.3 없음.
  • 왜 문제인가. 인용된 내용은 코드와 일치하므로 내용 drift는 아니다. 다만 근거를 확인하려는 사람이 도달할 수 없다.
  • 확인 방법. git grep -n '40\.3' -- docs
  • 후보. 참조를 실제 문서로 바꾸거나 인용만 남기고 절 번호를 뺀다.
  • 다음 단계. REFERENCE 후보(저장소 밖 문서를 절 번호로 인용하지 않는다).
확인된 설계(문제 아님)
  • 모순을 부팅 실패로 옮기는 16가지 규칙과, 그것이 실제로 시작 시 호출된다는 것
  • retry와 DLQ 간선을 하나의 그래프로 순회하고 다이아몬드를 오탐하지 않는 것
  • payload 검사를 permit 획득보다 먼저 두는 것
  • 두 개의 천장과 세 가지 슬롯 누수 방지
  • fair semaphore와 불균형 반납 차단
  • capability를 재시도 판단의 입력으로 두어 수행 불가능한 전략을 고르지 않는 것
  • 모든 기본값이 보수적인 것(재시도 없음·동시성 1·순서 보존·확인 최대)
  • DLQ 발행이 확인되기 전에는 원본을 정산하지 않는 것과 그 trade를 명시한 것
  • DLQ 헤더에 ReservedHeaders 상수를 쓰고 MessageHeaders.platform을 쓰는 것

Source anchors

id kind path revision what it proves limitations
MPO-001 registry src/config/architecture/modules.json 21234e38 deps 2개, memberships ["app-bootstrap"] 선언
MPO-002 build messaging-policy/build.gradle same 벤더 의존성 0
MPO-003 code .../policy/DestinationProfileValidator.java 전문 same §4.1 16규칙, §4.2 이중 간선 그래프 복잡도 미문서화(§17)
MPO-004 code .../policy/MessagingAdmissionController.java 전문 same §4.3 순서·두 천장·세 누수 방지
MPO-005 code .../policy/InFlightLimiter.java same fair semaphore, 불균형 반납 차단 "Section 40.3" 미해소
MPO-006 code .../policy/DefaultRetryDecisionEngine.java same §4.4 8단 판단 순서, capability 입력 production 호출 없음(§12.1)
MPO-007 code .../policy/{RetryPolicy,RetryMode,RetryDecision,RetryContext,BackoffCalculator,OrderingImpact}.java same 재시도 어휘 전체
MPO-008 code .../policy/DeadLetterOrchestrator.java same §4.7 발행-후-정산 불변식 production 호출 없음(§12.1)
MPO-009 code .../policy/{DeadLetterEnvelopeFactory,DeadLetterMetadata,DeadLetterPolicy,DeadLetterResult,SourceSettlement}.java same DLQ 봉투와 메타데이터 두 시각이 항상 같음(§17)
MPO-010 code .../policy/{DestinationProfile,PhysicalDestination,SchemaPolicy,ProducerPolicy,ConsumerPolicy,PayloadPolicy,CapabilityTier}.java same 목적지 정의 8타입과 기본값
MPO-011 test DestinationProfileValidatorTest (13) same 규칙별 거절, 교대 사이클, 다이아몬드
MPO-012 test MessagingAdmissionControllerTest (13) same 관문 동작 전수 실부하 아님
MPO-013 test RetryDecisionEngineTest (10) same 판단 순서와 백오프/지터 배선 미증명
MPO-014 test DeadLetterOrchestratorTest (6) same 정산 순서 불변식 배선 미증명
MPO-015 assembly messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:134,145,167,179,407,446,476 same 배선된 것과 만들어지기만 한 것 해당 leaf SSOT가 소유
MPO-016 cross-leaf code messaging-kafka/.../KafkaRetryExecutor.java same RetryDecision의 유일한 실행자 생성되지 않음
MPO-017 cross-leaf code messaging-runtime-core/.../DefaultDeliveryProcessor.java same 경쟁하는 재시도/DLQ 구현 해당 leaf SSOT가 소유
EVD-281 command evidence/raw/281-messaging-policy-retry-engine-unwired.txt same §12.1 전부 정적 검색. 정규화 생성자 패턴 사용
EVD-282 command ./gradlew :messaging:messaging-policy:test --rerun-tasks same 42 / 0 / 0 순수 단위

A19-MESSAGING-PULSAR-EXPERIMENTAL. messaging-pulsar-experimental

분석 중에는 messaging/MESSAGING-PULSAR-EXPERIMENTAL.md 파일이었다. 296줄.

messaging-pulsar-experimental 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 8파일 663줄, test 2파일 414줄 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-pulsar-experimental SSOT owner: messaging-pulsar-experimental integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • 선언 의존: messaging 계열 project 7 + vendor pulsar-client:4.0.3
  • runtime_memberships: [] — build-only · 등급 EXPERIMENTAL
파일 LOC
PulsarMessagingTransport 275
PulsarProfile 80
PulsarProfileValidator 66
PulsarPreSendRejection 65
PulsarSubscriptionMode 62
PulsarTransactionCapability · PulsarMessagePosition 49 · 49
PulsarSubscriptionType 17
main 합계 663
PulsarAdapterContractTest · PulsarSubscriptionGuardTest 289 · 125
Coverage ledger
scope count disposition reason
main/java/** 8 FULL_READ 663줄 전 본문
test/java/** 2 FULL_READ 414줄 전 본문 · 테스트 27개
build.gradle 1 FULL_READ 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 이 어댑터가 무엇이고 무엇이 아닌가

"This is an Experimental contract seam, not a Stable adapter. It exercises the transport SPI against a send operation the application supplies; it does not ship a Pulsar client bridge, producer lifecycle, or reconnection."

전송은 PulsarSendOperation 함수형 인터페이스로 주입된다 — 브로커 없이 검증 가능하게 만든 격리다.

2. 실패 분류 — 타입 있는 신호만 본다

if (cause instanceof PulsarPreSendRejection rejection)  REJECTED (CONFIGURATION)
boolean timedOut = cause instanceof TimeoutException;
 나머지 전부 AMBIGUOUS (TRANSIENT_INFRASTRUCTURE)

javadoc 이 이전 구현과 그 결함을 적는다.

"Classification used to read the exception's class simple name: "Timeout" meant ambiguous, anything else meant rejected. A class name is not part of Pulsar's contract — it changes between client versions — and defaulting the unknown case to REJECTED tells the caller nothing was transmitted, which is how the same entry is published to the bookies twice."

기본값이 모호로 바뀐 것이 핵심이다. 알 수 없는 실패에서 안전한 방향은 모호다.

확인된 성공은 복제 증거로 기록된다 — 전송 미래가 설정된 수의 저장 노드에 기록된 뒤에야 해소되므로 영수증이 아니라 복제 증거다.

3. 호출자의 마감을 존중한다

send.send(profile.topic(), request).toCompletableFuture()
    .orTimeout(request.options().timeout().toMillis(), MILLISECONDS)

주석이 이유를 적는다 — 멈춘 전송이 호출자가 요청한 마감이 아니라 SDK 기본값만큼 호출자를 붙들고 있었다.

4. 구독 형태가 보장을 결정한다

PulsarSubscriptionMode 가 구독 종류와 확인 방식을 함께 묶고 두 조합을 생성자에서 거부한다.

"A Key_Shared subscription with cumulative acknowledgement is not keyed ordering with a faster ack — cumulative ack over interleaved keys acknowledges messages from keys the consumer has not finished, so the combination silently loses the property the subscription type was chosen for."

그리고 검증기가 목적지의 순서 범위와 구독 종류의 합의를 요구한다. 목적지 전체 순서는 아예 거부한다.

5. 트랜잭션은 주석이 아니라 클래스로 거절한다

"Pulsar has transactions. The platform does not offer them, and the distinction matters enough to be a class rather than a comment: an operator reading the capability matrix needs to know the answer is 'not proven here', not 'the broker cannot do it'."

그리고 거절을 던지지 않고 값으로 돌려준다 — 호출부에서 throw 가 보이게 하기 위해서다.

10. 테스트 레인

두 테스트 414줄 · 27개.

PulsarAdapterContractTest 14개 — 복제 증거로서의 확인, 위치 반환, 시간 초과의 모호, 타입 있는 사전 거절만이 NOT_TRANSMITTED, 미인식 실패의 모호, 감싸인 실패의 모호, 호출자 마감, 적재물 상한, 닫힘, register 인자 검사, 능력 세 개.

PulsarSubscriptionGuardTest 13개 — 누적 확인 조합 거부 둘, 순서 범위 둘, 영 지연 거부, 확인 시간 초과 하한, 기본 프로파일이 확인 시간 초과를 끄는 것, 트랜잭션 미승격 둘, 위치 렌더링 셋, 그리고 §17.3 이 다루는 마지막 하나.

전송은 (topic, request) -> CompletionStage<PulsarMessagePosition> 람다로 주입된다. 성공·실패·영영 안 끝남을 테스트가 직접 만든다.

레인에 없는 것 둘. orderedStream() 을 확인하는 단언이 하나도 없다 — §17.1 의 어긋남이 살아남은 자리다. 그리고 register(spec) 를 실제 spec 으로 부르는 테스트가 없어서, 기본 소비자 팩토리가 던지는 PULSAR_CONSUMER_NOT_CONFIGURED 는 한 번도 실행되지 않는다(§17.3).

12. negative-space probes

12.1 도달성. build-only · experimental. PulsarMessagingTransport 는 자기 테스트에서만 만들어진다.

리프 밖에서 dev.caskeleton.messaging.pulsar 가 등장하는 곳은 전부 이름 문자열이다 — config/architecture/modules.json, messaging-testkit/CompatibilityMatrix, 그리고 그것을 읽는 두 테스트. 그중 CrossBrokerContractSuite:110-113 이 이 어댑터의 상태를 명시적으로 못 박는다.

assertThat(matrix.isComplete("messaging-pulsar-experimental"))  ;
assertThat(matrix.gapsFor("messaging-pulsar-experimental")).isNotEmpty();

즉 플랫폼의 호환성 표가 이 어댑터를 "빈칸이 있는 상태"로 기록하고 있고, 그것을 테스트가 지킨다. 등급 표기와 실제 상태가 어긋나면 저 테스트가 깨진다.

12.2 PulsarProfileValidator 는 선언 말고 아무 데도 없다.

$ grep -rn PulsarProfileValidator --include=*.java src/
src/…/pulsar/PulsarProfileValidator.java:20: public final class PulsarProfileValidator {

한 줄. 자기 선언뿐이다 — 리프 밖 참조가 없는 정도가 아니라 리프 안 참조도, 테스트도 없다. 그래서 §4 가 서술하는 "검증기가 목적지의 순서 범위와 구독 종류의 합의를 요구한다"는 판단은 코드로 적혀 있을 뿐 한 번도 실행된 적이 없다.

자매 어댑터(NATS)의 검증기도 같은 상태다(그쪽 §17.3). 다만 그쪽은 전송 javadoc 이 {@link} 로 가리키기라도 하는데, 이쪽은 그것조차 없다.

12.3 cumulativeAcknowledgement = true 를 만들 수 있는 조합이 없다.

if (cumulativeAcknowledgement && subscriptionType == KEY_SHARED) throw ;
if (cumulativeAcknowledgement && subscriptionType == SHARED)     throw ;

PulsarSubscriptionType 의 값은 그 둘뿐이다. 그러므로 이 record 의 두 번째 성분은 false 만 가질 수 있다.

의도의 흔적은 남아 있다 — PulsarSubscriptionType javadoc 이 ExclusiveFailover 를 "의도적으로 뺐다"고 적는데, Pulsar 에서 누적 확인이 정당한 것이 정확히 그 두 종류다. 즉 종류를 둘로 줄인 결정이 이 성분을 죽였다.

§4 는 이 짝지음을 "두 값이 함께 보장을 결정한다"고 서술한다. 지금 코드에서는 한 값이 다른 값을 언제나 결정한다. 두 거부 메시지가 서로 다른 이유를 대므로 문서로서는 살아 있고, 그래서 §17 이 아니라 여기에 적는다.

12.4 드리프트. 실험 등급 표기가 코드와 문서에서 일치한다. PulsarTransactionCapability.PROMOTED = false 와 두 능력 상수의 brokerTransaction=false 도 일치한다.

16. 확인하지 못한 것

  • 실제 Pulsar 브로커를 띄우지 않았다. 이 리프가 클라이언트 브리지를 싣지 않으므로 그럴 대상도 없다.
  • §17.1 의 두 능력 답이 실제 재시도 선택을 어떻게 가르는지 실행으로 재현하지 않았다.
  • 테스트를 실행하지 않았다. 27개 전부 본문으로만 확인했다.
  • §17.3 의 두 테스트가 실제로 무엇을 통과시키는지 디버거로 확인하지 않았다. register 의 첫 줄 널 검사와 assertThatThrownBy 가 단언하는 예외 타입으로 판정했다.

17. 손볼 것

17.1 P2 — 같은 어댑터의 능력을 두 곳이 다르게 답하고, 런타임이 쓰는 쪽이 record 의 문서화된 의미와 어긋난다

전송이 답하는 값:

SHARED_CAPABILITIES     = (true, true, true, true, false, false, true, true, false, false, true, true);
KEY_SHARED_CAPABILITIES = (true, true, true, true, false, true,  true, true, false, false, true, true);

검증기가 답하는 값:

public MessagingCapabilities capabilities(PulsarSubscriptionType subscriptionType) {
  boolean keyed = subscriptionType == PulsarSubscriptionType.KEY_SHARED;
  return new MessagingCapabilities(true, true, true, true, keyed, keyed, true, true, false, false, true, true);
}

다섯 번째 성분이 갈린다.

Key_Shared 에서 orderedStream keyedOrdering
PulsarMessagingTransport.capabilities(...) false true
PulsarProfileValidator.capabilities(...) true true

MessagingCapabilities 의 성분 문서가 판정 기준이다.

@param orderedStream the destination preserves order inside an ordering unit
@param keyedOrdering  order is preserved per key

Key_Shared 의 순서 단위는 키다. 그 단위 안에서 순서가 보존되므로 검증기 쪽이 문서화된 의미와 맞고, 전송 쪽은 keyedOrdering=true 이면서 orderedStream=false 라 자기 안에서 모순이다.

그리고 어긋난 쪽이 런타임이 읽는 쪽이다. capabilities(DestinationName) 이 SPI 메서드이고, orderedStream 은 이 저장소에서 production 코드가 실제로 읽는 세 능력 중 하나다 — DefaultRetryDecisionEngine 이 그 값이 있으면 순서 보존 재시도를 고른다.

결과적으로 Key_Shared 목적지가 키 단위 순서를 약속하면서 순서 보존 재시도를 받지 못한다.

등급. 리프가 미배선이라 오늘의 사고는 아니다. 두 답 중 하나를 고르는 것이 먼저이고, 그 다음이 한 곳에서만 답하게 만드는 것이다. 검증기의 capabilities 는 리프 밖 소비자가 없으므로 전송이 그것을 부르게 하는 쪽이 자연스럽다.

17.2 P3 — 닫힌 전송의 거절이 영구 업무 실패로 분류된다
private static TransportPublishResult rejectedLocally(String code, String message) {
  return new TransportPublishResult(new PublishResult(
      PublishCompletion.REJECTED, PublishEvidence.notTransmitted(), RoutingOutcome.NOT_APPLICABLE,
      Optional.empty(), 1, Duration.ZERO,
      Optional.of(FailureDescriptor.of(FailureCategory.PERMANENT_BUSINESS, code, message))));
}

두 호출자가 이 메서드를 쓴다.

PAYLOAD_TOO_LARGE       — 적재물이 상한을 넘음
PULSAR_TRANSPORT_CLOSED — "the transport is shutting down"

첫째는 영구 업무 실패가 맞다. 둘째는 아니다. 종료 중이라는 것은 이 세대의 사정이고, 다음 세대나 다른 인스턴스에서는 같은 메시지가 발행된다.

같은 파일의 classify 가 분류를 신중히 나눈다 — 사전 거절은 CONFIGURATION, 모호는 TRANSIENT_INFRASTRUCTURE. 닫힘만 그 규율 밖에 있다.

전송되지 않았다는 증거(notTransmitted)는 옳다. 어긋난 것은 범주뿐이다.

수정은 닫힘에 TRANSIENT_INFRASTRUCTURE 를 주거나, 두 호출자가 범주를 인자로 받게 하는 것이다.

17.3 P3 — 이름이 검사하지 않는 것을 검사한다고 말하는 테스트 둘

하나.

@Test
void theValidatorAcceptsAKeyedProfileOnKeyShared() {
  assertThatCode(() -> new PulsarProfile(, PulsarSubscriptionMode.keyShared(), ))
      .doesNotThrowAnyException();
}

본문에 PulsarProfileValidator 가 없다. 만들지도, 부르지도 않는다. 확인하는 것은 PulsarProfile 생성자가 키 공유 모드를 거부하지 않는다는 사실뿐이다.

이 리프에서 검증기를 언급하는 유일한 테스트 이름이 이것이고(§12.2), 그래서 이름만 읽으면 검증기에 커버리지가 있다고 읽힌다.

둘.

@Test
void aTransportWithoutAConsumerFactoryRefusesToRegisterRatherThanReturningNothing() {
  assertThatThrownBy(() -> confirming().register(null)).isInstanceOf(NullPointerException.class);
}

이름이 말하는 것은 "소비자 팩토리 없이 만든 전송이 등록을 거절한다"이다. 그 거절은 4-인자 생성자가 심어 두는 기본 팩토리에 있다.

spec -> { throw new MessagingCapabilityUnavailableException(
    "PULSAR_CONSUMER_NOT_CONFIGURED", "this Pulsar transport was created without a consumer factory"); }

그런데 테스트는 register(null) 을 부른다. register 첫 줄의 Objects.requireNonNull(spec, …) 에서 NullPointerException 이 나고, 팩토리까지 가지 않는다. 단언하는 예외 타입도 NullPointerException 이지 MessagingCapabilityUnavailableException 이 아니다.

결과적으로 PULSAR_CONSUMER_NOT_CONFIGURED 는 이 저장소에서 한 번도 실행되지 않는 코드다.

왜 P3 인가. 어느 쪽도 잘못된 동작을 통과시키지 않는다 — 두 테스트가 확인하는 것은 사실이다. 문제는 커버리지 지도가 틀렸다는 것이고, 그래서 §12.2 의 "검증기에 호출자가 없다"가 지금까지 눈에 띄지 않았다.

수정. 첫째는 new PulsarProfileValidator().validate(profile, KEY_SHARED, true) 를 부르고, 키 순서 목적지를 SHARED 로 넘겼을 때 거부되는 짝 테스트를 붙인다. 둘째는 유효한 TransportConsumerSpec 을 넘겨 MessagingCapabilityUnavailableException 과 그 코드를 단언한다. 두 수정 모두 새 production 코드를 요구하지 않는다.

확인된 설계(문제 아님)
  • 알 수 없는 실패의 기본값을 모호로 둔 것과, 이전 구현의 결함을 javadoc 에 남긴 것.
  • 클래스 이름이 아니라 타입 있는 신호로 분류하는 것 — 클래스 이름은 클라이언트 판본 사이에서 바뀐다.
  • 확인을 복제 증거로 기록한 것 — 영수증과 구분한다.
  • 호출자의 마감을 orTimeout 으로 존중하는 것.
  • 구독 종류와 확인 방식을 한 record 로 묶고 두 조합을 생성자에서 거부한 것.
  • 트랜잭션 미승격을 클래스로 표현하고, 거절을 던지지 않고 값으로 돌려주는 것.
  • 전송 연산을 함수형 인터페이스로 분리해 브로커 없이 검증 가능하게 만든 것.
  • 확인 시간 초과를 기본에서 끄고 그 이유를 적은 것 — "an ack timeout redelivers messages from handlers that are merely slow." 테스트가 기본값이 비어 있음을 지킨다.
  • 음수 확인 재배달 지연이 곧 백오프라는 것을 밝히고 0 을 거부한 것 — 0 은 실패하는 핸들러를 브로커 대상 스핀 루프로 바꾼다.
  • 확인 시간 초과 하한을 Pulsar 자신의 하한(10초)으로 둔 것 — 브로커가 어차피 거부할 값을 시작 시점에 거부한다.
  • 메시지 위치를 불투명 문자열이 아니라 네 조각으로 분해해 들고 있는 것 — 배치 메시지는 id 를 공유하므로 batchIndex 가 개별 메시지를 주소 지정 가능하게 만드는 유일한 조각이다.
  • Exclusive · Failover 구독을 노출하지 않은 것과 그 근거 — 목적지 프로파일이 이미 소유한 토폴로지 결정을 두 곳에서 설정하게 만들지 않는다. (그 결정의 부작용은 §12.3.)

Source anchors

src/messaging/messaging-pulsar-experimental/build.gradle
main/java/…/pulsar/PulsarMessagingTransport.java:1-275
main/java/…/pulsar/PulsarProfileValidator.java:1-66
main/java/…/pulsar/PulsarSubscriptionMode.java:1-62
main/java/…/pulsar/PulsarTransactionCapability.java:1-49
main/java/…/pulsar/PulsarProfile.java:1-80
main/java/…/pulsar/PulsarPreSendRejection.java:1-65
main/java/…/pulsar/PulsarMessagePosition.java:1-49
main/java/…/pulsar/PulsarSubscriptionType.java:1-17
test/java/…/pulsar/PulsarAdapterContractTest.java:1-289
test/java/…/pulsar/PulsarSubscriptionGuardTest.java:1-125
src/messaging/messaging-testkit/…/CrossBrokerContractSuite.java:110-113 (호환성 표의 미완 기록)
src/messaging/messaging-core-api/…/destination/MessagingCapabilities.java:11-36 (성분 의미)
src/messaging/messaging-policy/…/DefaultRetryDecisionEngine.java (orderedStream 소비)

A19-MESSAGING-RABBIT. messaging-rabbit

분석 중에는 messaging/MESSAGING-RABBIT.md 파일이었다. 404줄.

messaging-rabbit 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) — src/main production 20파일 2,443줄 + src/test 10파일 1,727줄 축자 통독 완료. STRUCTURAL_ONLYgradle.lockfile 하나. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-rabbit SSOT owner: messaging-rabbit integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • runtime_memberships: ["app-bootstrap"] — 클래스패스에 올라간다
  • 도달성: 없다. 제공자 선택이 rabbit 을 이름으로 거부한다(§12.1)
파일 LOC 참조
RabbitConfirmCoordinator 279 전송 + 테스트
RabbitMessagingTransport 246 테스트만
RabbitConsumerRegistrar 238 테스트만
RabbitDeliveryMapper 195 소비자 + 테스트
RabbitHeaderMapper 180 매퍼 둘 + 테스트
RabbitSecurityConfigurer 179 스타터 빈만 — 호출처 없음
RabbitBatchConsumerRegistrar 165 테스트만
RabbitTopologyProfile 137 네이티브 DLQ 능력 + 테스트
RabbitDeadLetterPublisher 130 자기 파일 밖 참조 0
RabbitPublishFailureClassifier 117 전송 + 테스트
RabbitSettlementController 84 소비자 + 테스트
RabbitProfileValidator 83 스타터 시작 검증
RabbitPublishMapper 72 전송
RabbitNativeDeadLetterCapability 70 DLQ 발행자 + 테스트
RabbitRetryQueueTopology 57 테스트만
RabbitSettlementOperations 50 인터페이스 — 구현은 테스트 셋뿐
RabbitBrokerProfile 50 설정 컴파일
RabbitChannelPublisher 42 인터페이스 — production 구현 0
RabbitPublishReference 37 좌표
RabbitRequestReply 32 자기 파일 밖 참조 0

main 총 20파일 / 2,443줄.

Coverage ledger
scope count disposition reason
main/java/** 20 FULL_READ 2,443줄. 위 표가 전부
test/java/** 10 FULL_READ 1,727줄
build.gradle 1 FULL_READ 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일 — 생성물

UNCLASSIFIED 0.

"참조" 열은 2026-09-01 재통독에서 저장소 전체 grep 으로 채웠다. 이전 판의 Source anchors 는 절반을 괄호 하나로 묶어 두었고, 그 괄호 안에 §17.2·§17.3·§17.4 가 있었다.


1. 이 어댑터의 중심 — 확인과 반환은 다른 질문에 답한다

"AMQP delivers a return before the confirm for an unroutable message, so a naive adapter that completes on the confirm reports success for a message the broker threw away. The coordinator therefore keeps each publish pending until the confirm arrives, and remembers whether a return was seen first."

네 결과가 나온다 — 확인+미반환은 CONFIRMED, 확인+반환은 UNROUTABLEREJECTED, 부정 확인은 REJECTED, 확인 미도착은 AMBIGUOUS.

이 리프에서 가장 중요한 판단이고, 실브로커 시험(RabbitBrokerIT.anUnroutablePublishIsRejectedEvenThoughTheExchangeConfirmedIt)이 그것을 붙든다. 그리고 그 시험이 production 에 없는 조각을 스스로 채워 넣는다(§17.2).

2. 자료구조 선택이 결함 수정이다

private final ConcurrentSkipListMap<Long, PendingPublish> pending = new ConcurrentSkipListMap<>();

"Ordered because a Rabbit confirm carries a multiple flag meaning 'everything up to and including this tag'. Resolving one sequence per confirm — which is what a hash map forces — leaves every earlier publish pending forever: the caller's stage never completes and the entry is never removed, so the map grows for the life of the connection."

confirmed(sequence, multiple=true, …)headMap(sequence, true) 로 범위를 해소한다. 전용 시험이 있다.

3. 부정 확인의 증거를 전송됨으로 기록한다

// A NACK is the broker's answer to a frame it received. Recording it as never sent contradicts the
// very evidence that produced it, and a caller reading the evidence would conclude the message can
// be re-sent freely.

REJECTED + TRANSMITTED + ConfirmationLevel.NONE. 완결 상태와 전송 증거를 분리해서 다루는 곳이 이 가족에서 여기와 Kafka 뿐이다.

4. 소비·정착·죽은 편지의 세 규율

좁은 catch. RabbitConsumerRegistrar.onMessage 가 디코딩만 감싸는 안쪽 try 를 따로 둔다.

"One catch around decode, the handler and the settlement meant a business failure or an ACK that could not be written was recorded as an undecodable payload and discarded — a message that should have been retried, deleted instead."

정착하지 않은 핸들러. 완료했는데 정착하지 않으면 대신 ack 하지 않고 requeue 한다 — "acknowledging on its behalf would silently drop it".

네이티브 죽은 편지. RabbitNativeDeadLetterCapability 가 두 조건을 모두 요구한다.

"If the dead-letter exchange is unroutable — nobody bound a queue to it, or the binding was removed — the broker discards the message silently and the reject still succeeds."

5. 자격증명은 연결 시도마다 해석된다

"RabbitMQ client connections are long-lived and reconnect on their own, so a factory holding a credential from startup will happily reconnect with a revoked one for as long as the process runs — the reconnect is exactly the moment a rotated credential should take effect."

AmqpCredentials 가 record 가 아니라 class 인 이유도 적혀 있다 — 비밀을 지우려면 가변이어야 하고, record 가 char[] 를 동등성에 쓰면 같은 자재를 가진 둘이 서로 다르다고 판정된다.

6. 시작 검증

RabbitProfileValidator 가 여덟을 요구한다 — Stable 에 확인·반환·mandatory, 소비자 auto-ack 금지, prefetch ≥ 1, 확인 마감 양수, 운영에 TLS·인증. 그리고 목적지 검증이 둘 더 — 작업 큐에 쿼럼 큐, 교환기나 큐 중 하나.

Kafka 쪽과 달리 이 검증기는 스타터에서 StartupProfileValidation 으로 감싸여 있다. 다만 그 자동 설정 자체가 도달하지 않는다(§12.1).

10. 테스트 레인

10파일 1,727줄.

파일 무엇을 붙드나
RabbitRuntimeTest 309 전송 4경로 + 소비자 7경로(일시정지·배수·미디코딩·핸들러 실패·미정착·close)
RabbitContractHarness 304 공유 어댑터 계약을 production 조정자·정착 제어기 위에서
RabbitBrokerIT 232 실브로커 rabbitmq:4.3-management — 반환-먼저-확인
RabbitTopologyAndBatchTest 218 쿼럼 요구·DLX 논리·실패 분류·배치 누적
RabbitProfileValidatorTest 184 검증기 여덟 규칙
RabbitConfirmCoordinatorTest 167 상태 기계 12경로(다중 확인·채널 종료 포함)
RabbitEnvelopeRoundTripTest 152 헤더 왕복·위조 거부
RabbitSettlementControllerTest 105 일회 종결·지연 재시도 큐 인자
RabbitAdapterContractTest · RabbitFixtureProfiles 24 · 32 계약 실행·픽스처

RabbitAdapterContractTest 의 javadoc 이 이 레인의 요점을 적는다.

"Two brokers with completely different machinery — offsets and commits versus delivery tags and confirms — answering the same seven questions the same way is what makes the logical destination abstraction real rather than aspirational."

12. negative-space probes

12.1 도달성 — 리프 전체가 production 호출자를 갖지 않는다.

전송을 만들려면 RabbitChannelPublisher 구현이 필요하다. 저장소 전체에서 그 인터페이스의 구현은 테스트의 익명 클래스 둘(RabbitRuntimeTest:49, RabbitBrokerIT:709)뿐이다. 따라서 new RabbitMessagingTransport(...) 도 테스트에만 있고, RabbitConsumerRegistrar·RabbitBatchConsumerRegistrar 도 마찬가지다.

그리고 그 사실이 플랫폼 쪽에 이름으로 기록되어 있다.

// MessagingProviderSelection
static final Map<String, String> BROKERS_WITHOUT_A_TRANSPORT = Map.of(
    "rabbit",
    "the Rabbit adapter ships its validators and security configuration but no MessagingTransport: "
        + "its native channel publisher is not implemented, so a publish has nothing to travel on");

app.messaging.broker=rabbit 은 시작 오류이고, 전용 시험이 그 메시지를 단언한다. 그래서 RabbitMessagingAutoConfiguration 98줄도 도달하지 않는다.

이 리프의 품질과 도달성이 정반대다. 코드는 이 가족에서 가장 정교한 축이고 — 반환-먼저-확인 상태 기계, multiple 범위 해소, 정착 일회성, 네이티브 DLQ 의 조건부 신뢰 — 실행 경로는 없다.

12.2 리프 자체 기준으로도 죽은 둘.

파일 LOC 상태
RabbitDeadLetterPublisher 130 자기 파일 밖 참조 0 — production 도 테스트도 부르지 않는다
RabbitRequestReply 32 인터페이스. 구현 0, 테스트 0, 호출 0

RabbitDeadLetterPublisher 가 담고 있는 것이 §4 의 세 번째 규율 — 네이티브 경로와 플랫폼 발행 중 어느 쪽을 쓸지 한 곳에서 결정한다는 판단 — 인데, 그 결정을 내리는 코드를 아무도 부르지 않는다. 그 판단의 근거가 되는 RabbitNativeDeadLetterCapability 는 테스트가 있다. 즉 판단의 재료는 시험되고 판단 자체는 시험되지 않는다.

RabbitRequestReply 는 M2 능력의 인터페이스 선언이다. javadoc 이 왜 제한적으로 제공하는지를 적는데("a synchronous call wearing an asynchronous costume"), 제공되는 것이 없다.

12.3 대조군 — 재시도 헤더 오염의 처리가 두 어댑터에서 갈린다. 두 어댑터의 attemptOf 는 같은 fail-closed 결정을 같은 문구로 적는다.

// "the message is quarantined rather than restarting its retry budget"
throw new MessagingConfigurationException("RETRY_ATTEMPT_MALFORMED", );

그런데 소비자가 그 던짐을 받는 위치가 다르다.

어댑터 attemptOf 호출 위치 결과
Rabbit toMetadata 안 → 디코딩 실패 catch 안쪽 operations.discard(tag, …) — DLX 가 있으면 죽은 편지로
Kafka 디코딩 catch 바깥의 두 번째 블록 requeueAfterFailure() → 무한 pause-and-seek(messaging-kafka §17.4)

같은 판단, 반대 결과다. Rabbit 쪽이 javadoc 이 약속한 것에 가깝다.

12.4 대조군 — pause 의 뜻이 SPI 하나 뒤에서 두 가지다.

어댑터 반환 시점 의미
Kafka 다음 폴 주기 consumer.pause() — 브로커에서 더 가져오지 않는다
Rabbit 즉시 완료 onMessagefalse 를 답한다 — 리스너 컨테이너가 계속 밀고, 미확인으로 재배달된다

Kafka 쪽 javadoc 은 왜 즉시 완료하지 않는지를 명시한다("'paused' cannot be true until the loop says so"). Rabbit 쪽에는 그 대비 서술이 없다. §17.4.

12.5 드리프트. 검증기가 강제하는 항목과 어댑터가 실제로 보내는 플래그(mandatory)가 일치한다.

16. 확인하지 못한 것

  • 실제 브로커로 반환-먼저-확인 순서를 재현하지 않았다. RabbitBrokerIT 가 그 레인이고 컨테이너가 필요하다.
  • 지연 재시도 큐 토폴로지를 실제로 선언해 보지 않았다.
  • §17.2 를 실행으로 재현하지 않았다. RabbitHeaderMapper.toProperties 전문에 순번 헤더가 없다는 것과, RabbitBrokerIT 가 자기 publish 람다에서 x-seq 를 붙인다는 것으로 판정했다.
  • RABBIT-CR-DEMO(§17.3)를 실제 브로커에 붙여 보지 않았다. 이름과 RabbitMQ 의 기본 활성 상태로 판정했다.
  • gradle.lockfile 은 읽지 않았다(STRUCTURAL_ONLY).

17. 손볼 것

17.1 P3 — 확인 등급이 요구에서 파생되고, 그 요구를 뒷받침하는 강제는 목적지 종류 하나에만 걸린다
ConfirmationLevel level =
    requirement == ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK
        ? ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK
        : ConfirmationLevel.BROKER_ACK;

증거의 등급이 브로커가 무엇을 했는지가 아니라 프로파일이 무엇을 요구했는지 에서 나온다.

대부분의 경우 이 파생은 성립한다. 두 강제가 그것을 받쳐 준다.

  • RabbitHeaderMapper.toProperties 가 배달 모드를 무조건 PERSISTENT 로 둔다. RabbitMQ 는 지속 메시지를 디스크에 쓴 뒤에 확인한다.
  • RabbitProfileValidator.validateDestination 이 내구 작업 큐에 쿼럼 큐를 요구한다. 쿼럼 큐의 확인은 다수 복제 뒤에 온다.

빈틈은 둘째 강제의 범위다.

if (destination.kind() == DestinationKind.WORK_QUEUE && !broker.quorumQueues()) { throw ; }

작업 큐가 아닌 목적지에는 쿼럼 요구가 없다. 교환기로 발행하는 목적지가 REPLICATION_OR_PERSISTENCE_ACK 를 요구하면, 그 교환기에 바인딩된 큐가 고전 큐여도 어댑터는 그 등급을 보고한다. 지속 모드 덕분에 디스크 기록은 보장되지만 복제는 보장되지 않는다.

이 저장소의 규율은 증거가 관측에서 나와야 한다는 것이다 — MessagingCapabilities 의 javadoc 이 "a silently weakened guarantee is indistinguishable from a working one until the incident" 라고 적는다.

수정은 쿼럼 요구를 목적지 종류가 아니라 요구된 확인 등급 에 걸거나, 작업 큐가 아닌 목적지에서는 등급을 BROKER_ACK 로 낮추는 것이다.

17.2 P2 — 반환을 순번에 맞추는 조각이 production 에 없고, 시험이 그 자리를 스스로 메운다

이 어댑터의 핵심 보장(§1)은 반환과 확인을 같은 발행 에 묶는 데 달려 있다. 묶는 열쇠는 순번이다.

public void returned(long sequence) {  }        // RabbitConfirmCoordinator
public void onReturn(long sequence) {  }        // RabbitMessagingTransport

그런데 AMQP 의 basic.return 콜백은 순번을 주지 않는다. 교환기·라우팅 키·속성·본문만 온다. 그래서 발행자가 순번을 메시지에 실어 보내고 반환에서 되읽어야 한다.

RabbitHeaderMapper.toProperties 전문에 그런 헤더가 없다. 쓰는 것은 msg.* 예약 헤더들과 AMQP 의 messageId·correlationId·timestamp·deliveryMode 뿐이다.

그 조각이 존재하는 곳은 시험 하나다.

// RabbitBrokerIT
channel.addReturnListener(returned ->
    transport.onReturn(Long.parseLong(returned.getProperties().getHeaders().get("x-seq").toString())));

private static Map<String, Object> withSequence(MessageProperties source, long sequence) {
  headers.put("x-seq", Long.toString(sequence));   // ← 시험이 직접 붙인다
}

그 메서드의 javadoc 이 문제를 정확히 서술한다.

"A returned message arrives without its publish sequence number, so the adapter has to carry one itself to correlate the return with the pending publish."

"the adapter has to" 인데 어댑터는 하지 않는다. RabbitChannelPublisher 의 javadoc 은 등록 경합(확인이 basicPublish 반환보다 먼저 올 수 있다)만 설명하고 이 상관 문제는 언급하지 않는다.

결과는 이렇다. 언젠가 RabbitChannelPublisher 를 구현하는 사람은 이 헤더 규약을 다시 발명해야 하고, 발명하지 않으면 onReturn 이 호출되지 않아 unroutable 발행이 CONFIRMED 로 보고된다 — 이 어댑터가 존재하는 이유로 든 바로 그 실패다.

수정은 순번 헤더를 RabbitHeaderMapperRabbitPublishMapper 로 올려 production 계약으로 만들고, 그 이름을 RabbitChannelPublisher javadoc 에 적는 것이다. 지금은 그 규약이 시험 파일 20줄에만 있다.

17.3 P3 — SCRAM 자격을 RabbitMQ 의 데모 기구로 조용히 매핑한다
case BrokerCredentialProfile.SaslScram scram -> {
  CredentialRuntime resolved = credentials.resolve(scram.credentialId(), now);
  yield new AmqpCredentials("RABBIT-CR-DEMO", scram.credentialId(), resolved.material(), profile.tlsEnabled());
}

RABBIT-CR-DEMO 는 RabbitMQ 의 시연용 challenge-response 인증 기구(rabbit_auth_mechanism_cr_demo)의 이름이고 기본 활성이 아니다. RabbitMQ 는 SCRAM-SHA 를 구현하지 않으므로 SaslScram 에 대응하는 AMQP 기구가 없다는 것 자체는 사실이다.

문제는 그 사실을 다루는 방식이 같은 파일 안에서 일관되지 않다는 것이다.

case BrokerCredentialProfile.Nkey ignored ->
    throw new IllegalArgumentException("NKey credentials are a NATS concept, not an AMQP one");   // ← 거부
case BrokerCredentialProfile.OAuth2 oauth -> {
  // RabbitMQ's OAuth 2 plugin takes the token in the password field of a PLAIN exchange.
  yield new AmqpCredentials("PLAIN", );                                                          // ← 주석으로 근거
}
case BrokerCredentialProfile.SaslScram scram -> yield new AmqpCredentials("RABBIT-CR-DEMO", );   // ← 둘 다 없다

그리고 이웃 어댑터의 같은 클래스가 정확히 이 상황에 대한 규범을 적어 두었다.

"Refused rather than half-configured. Setting the mechanism name without a callback handler produces a client that authenticates with nothing and fails at connect time, which is later and harder to attribute than failing here." — KafkaSecurityConfigurer

SaslScram 에도 그 규범이 적용되어야 한다. 플러그인이 없는 브로커에서는 handshake 가 알아보기 어려운 오류로 실패하고, 있는 브로커에서는 시연용 기구로 인증한다.

수정은 Nkey 와 같이 거부하거나, PLAIN 으로 매핑하고 그 이유를 주석으로 남기는 것이다. 어느 쪽이든 지금처럼 말없이 데모 기구를 고르는 것보다 낫다.

17.4 P3 — 능력 상수의 delayedDelivery 가 무조건 참이고, 그 지연을 제공할 토폴로지는 조립되지 않는다
private static final MessagingCapabilities CAPABILITIES =
    new MessagingCapabilities(true, true, true, false, false, false, false, true, false, false, true, true);
//                                                                        ^^^^ delayedDelivery

이 플래그는 읽힌다.

// DefaultRetryDecisionEngine:64
if (policy.mode() == RetryMode.BROKER_DELAYED && context.capabilities().delayedDelivery()) {  }

그런데 지연을 실제로 만드는 것은 RabbitRetryQueueTopology 이고, 그 클래스는 자기 파일과 시험 하나 밖에서 참조되지 않는다. 어떤 production 코드도 그 큐를 선언하지 않는다.

그리고 그 클래스의 javadoc 이 이 지연의 성질을 정확히 적는다.

"TTL expiry is evaluated at the head of the queue, so mixed delays in one retry queue do not expire independently."

즉 제공되는 것은 "메시지별 지연" 이 아니라 "재시도 큐 하나당 TTL 하나" 다. 능력 모델에는 그 구분을 표현하는 자리가 없고, 상수는 프로파일과 무관하게 참을 답한다.

Kafka 는 같은 칸을 false 로 둔다. 그래서 이 플래그의 두 값이 "지연 있음/없음" 이 아니라 "지연을 흉내낼 토폴로지를 선언할 수 있음/없음" 을 뜻하게 된다.

수정은 능력을 전송 상수가 아니라 목적지의 재시도 큐 선언에서 파생시키는 것이다. 이 리프가 조립되지 않는 동안에는 P3 이고, RabbitChannelPublisher 구현이 생기는 날 함께 봐야 한다.

17.5 P3 — pause 의 의미가 SPI 하나 뒤에서 두 브로커에 다르게 구현된다
@Override public CompletionStage<Void> pause(String scope) {
  pausedScopes.add(scope == null ? "" : scope);
  return CompletableFuture.completedFuture(null);      // ← 즉시 완료
}

호출자가 이 단계를 기다리고 나면 "일시정지되었다" 고 읽는다. 실제로 일어난 것은 onMessage 가 이후 배달에 false 를 답하기 시작한 것뿐이고, 리스너 컨테이너는 계속 배달을 밀며 그 배달들은 미확인 상태로 재배달된다. 즉 정지가 아니라 거부-재배달 루프다.

Kafka 쪽은 같은 SPI 를 정반대로 구현하고 그 이유를 적는다.

"The returned stage completes after the poll loop has actually applied the change, so a caller that awaits it knows the consumer is paused rather than merely asked to pause… there is no safe way to touch the consumer from another thread, so 'paused' cannot be true until the loop says so."

AMQP 에는 대응하는 수단이 있다 — basicCancel 로 소비자를 취소하거나 컨테이너를 멈추는 것. 지금 구현이 그것을 하지 않는 이유는 어디에도 없다.

전용 시험(aPausedQueueRefusesDeliveriesSoTheBrokerRedeliversThem)의 이름이 이미 실제 동작을 정확히 말한다. 그러므로 수정은 둘 중 하나다 — 컨테이너를 실제로 멈추거나, SPI 의 javadoc 에 "브로커에 따라 정지가 거부-재배달일 수 있다" 를 명시하는 것.

확인된 설계(문제 아님)
  • 확인과 반환을 두 질문으로 나누고, 반환-먼저 순서를 상태 기계로 다룬 것.
  • 정렬된 맵을 골라 multiple 확인의 범위 해소를 가능하게 한 것과, 해시 맵이 만들었을 누수를 javadoc 에 남긴 것.
  • 부정 확인의 증거를 전송됨으로 기록한 것과 그 근거.
  • 채널 종료를 모호로 완결시킨 것 — 보류로 남기면 호출자가 매달린다.
  • 순번 예약을 발행과 분리한 것 — 확인이 basicPublish 반환을 앞지를 수 있다.
  • 동기 발행 실패를 던지지 않고 분류기를 거쳐 스테이지로 돌려주는 것.
  • 적재물 크기와 종료 상태를 채널 앞에서 검사해 미전송 증거로 실패시키는 것.
  • 정착의 일회성과 재사용된 배달 태그의 위험을 명시한 것.
  • 디코딩만 감싸는 좁은 catch — 넓은 catch 가 재시도 가능한 실패를 삭제로 바꾸던 형태를 고쳤다.
  • 정착하지 않은 핸들러를 대신 ack 하지 않고 requeue 하는 것.
  • 요구 재큐 대신 지연 재시도 큐를 쓴 것과, TTL 이 큐 머리에서 평가된다는 한계를 javadoc 에 남긴 것.
  • 배수 중 진행 배달을 끝내게 한 것.
  • 네이티브 죽은 편지를 검증된 곳에서만 쓰고 나머지는 공유 조율자에 위임한 것, 그리고 네이티브 경로의 증거를 BROKER_ACK 로만 주장한 것.
  • 자격증명을 연결 시도마다 해석하고 짧은 수명 객체로 넘긴 것, AmqpCredentials 를 record 가 아니라 class 로 둔 것과 그 근거.
  • 내구 작업 큐에 쿼럼 큐를 요구한 것과 그 근거.
  • 배치 누적에 나이 경계를 필수로 만든 것 — 조용한 큐가 마지막 메시지를 미확인으로 붙들지 않게.
  • prefetch 가 배치 크기보다 작으면 교착이라는 것을 거부로 표현한 것.
  • 배치를 settlableAsBatch=false 로 보고한 것 — AMQP multiple-ack 은 진행 중인 작업까지 정착시킨다.

Source anchors

src/messaging/messaging-rabbit/build.gradle
main/java/…/rabbit/RabbitConfirmCoordinator.java:1-279  (§17.2 returned:74-79)
main/java/…/rabbit/RabbitMessagingTransport.java:1-246  (능력 상수 41-43 · §17.4)
main/java/…/rabbit/RabbitConsumerRegistrar.java:1-238   (§17.5 pause:694-697)
main/java/…/rabbit/RabbitDeliveryMapper.java:1-195      (§12.3 attemptOf:133-153)
main/java/…/rabbit/RabbitHeaderMapper.java:1-180        (§17.1 배달 모드 235 · §17.2 순번 헤더 부재)
main/java/…/rabbit/RabbitSecurityConfigurer.java:1-179  (§17.3 switch 438-463)
main/java/…/rabbit/RabbitBatchConsumerRegistrar.java:1-165
main/java/…/rabbit/RabbitTopologyProfile.java:1-137
main/java/…/rabbit/RabbitDeadLetterPublisher.java:1-130 (§12.2 참조 0)
main/java/…/rabbit/RabbitPublishFailureClassifier.java:1-117
main/java/…/rabbit/RabbitSettlementController.java:1-84
main/java/…/rabbit/RabbitProfileValidator.java:1-83     (§17.1 validateDestination:543-555)
main/java/…/rabbit/RabbitPublishMapper.java:1-72
main/java/…/rabbit/RabbitNativeDeadLetterCapability.java:1-70
main/java/…/rabbit/RabbitRetryQueueTopology.java:1-57   (§17.4)
main/java/…/rabbit/{RabbitSettlementOperations:1-50, RabbitBrokerProfile:1-50, RabbitChannelPublisher:1-42,
                    RabbitPublishReference:1-37, RabbitRequestReply:1-32}
test/java/…/rabbit/ 10파일 1,727줄 (RabbitRuntimeTest:309 · RabbitContractHarness:304 · RabbitBrokerIT:232 …)
test/java/…/rabbit/RabbitBrokerIT.java:726-733, 786-813 (§17.2 시험이 메우는 x-seq 규약)
messaging-spring-boot-starter/…/MessagingProviderSelection.java:64-69 (§12.1 rabbit 거부)
messaging-policy/…/DefaultRetryDecisionEngine.java:64 (§17.4 delayedDelivery 소비처)

A19-MESSAGING-RELIABILITY-API. messaging-reliability-api

분석 중에는 messaging/MESSAGING-RELIABILITY-API.md 파일이었다. 793줄.

messaging-reliability-api 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-reliability-api SSOT owner: messaging-reliability-api integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-reliability-api
  • canonical state analysisFile: §A19-MESSAGING-RELIABILITY-API
  • source path: src/messaging/messaging-reliability-api
  • registry allowed_dependencies: ["messaging-core-api"]
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 13
production LOC 817
패키지 1 (dev.caskeleton.messaging.reliability)
test 파일 0 — src/test 디렉터리가 없다
외부(비프로젝트) 의존성 0

13개 타입:

타입 leaf 밖 참조
Outbox OutboxRepository · OutboxRecord · OutboxCanonicalMetadata · OutboxStatus · OutboxLease · OutboxTransitionResult 7 · 13 · 8 · 7 · 6 · 6
Inbox InboxRepository · InboxRecord · InboxResult · IdempotentMessageHandler · TransactionalMessageAction 6 · 0 · 2 · 1 · 1
기타 ClaimCheckReference · ReliableMessagePublisher 6 · 0
Coverage ledger
scope/file group count disposition reason
src/main/java/** (13) 13 FULL_READ 전 파일 본문 확인
src/test/** 0 존재하지 않음(§10)
build.gradle 1 FULL_READ 5줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

이 leaf는 effectively-once 처리의 계약을 소유한다. 구현이 없다 — 13개 중 인터페이스 5개, record 5개, enum 3개이고 실행 가능한 로직은 record 생성자 검증과 isExpired/expiredAt 술어 정도다. 벤더 의존성 0, 저장소 기술 중립이다.

세 개의 독립적인 메커니즘을 담는다.

Outbox — dual-write 문제의 답.

// ReliableMessagePublisher.java:14-15
 * <p>This is the answer to the dual-write problem. Writing to the database and publishing to the
 * broker in the same method cannot be made atomic; writing both to the database can.

Inbox — 소비 측 중복 제거.

// InboxRepository.java:9-13
 * <p>{@link #reserve} must run inside the same database transaction as the handler's side effect.
 * That is the entire mechanism: the uniqueness constraint on the inbox row and the business write
 * commit together, so a redelivered message either finds the row already present and skips, or
 * writes both. Reserving in a separate transaction reintroduces exactly the gap the Inbox exists to
 * close.

Claim Check — 브로커 밖 payload 참조.

그리고 셋의 관계를 OutboxRecord가 명시한다.

// OutboxRecord.java:21-24
 * <p>What the outbox does not do is remove duplicates. A relay that cannot confirm a publish will
 * retry it, and the same message may reach the broker twice. Effectively-once processing comes from
 * this row carrying a stable {@code messageId} and the consumer having an Inbox  not from the
 * outbox alone.

Outbox 하나로는 부족하다는 것을 타입의 javadoc이 직접 말한다. 이 저장소에서 반복되는 "보장을 과대 진술하지 않는다"의 예다.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api) 하나.

나가는 것: messaging-outbox-jdbc-postgresql, messaging-inbox-jdbc-postgresql, messaging-claim-check, messaging-spring-boot-starter.

구현 leaf가 셋 있고 전부 배선된다.

포트 구현 조립
OutboxRepository messaging-outbox-jdbc-postgresql/JdbcOutboxRepository starter MessagingReliabilityAutoConfiguration
InboxRepository messaging-inbox-jdbc-postgresql/JdbcInboxRepository 같음
IdempotentMessageHandler messaging-inbox-jdbc-postgresql/TransactionalInboxHandler transactionalInboxHandler bean
ReliableMessagePublisher 없음

ReliableMessagePublisher는 구현도 소비자도 0이다(§12.1). Outbox에 행을 쓰는 애플리케이션 측 진입점인데, 그 진입점이 없다.

이 leaf 자체는 Spring 주석을 갖지 않는다.


3. 패키지/컴포넌트 지도

Outbox
  ReliableMessagePublisher.addToOutbox(dest, envelope)   ← 구현 0
        ↓ (쓰기)
  OutboxRecord ─┬─ messageId / destination / type / version / contentType / payload / headers
                ├─ OutboxCanonicalMetadata  (provenance 10필드)
                └─ status / attempts / leaseExpiresAt / lastFailureCode
        ↓ (릴레이)
  OutboxRepository ─┬─ append
                    ├─ [구세대] leaseBatch → List<OutboxRecord>
                    │            markPublished/markAmbiguous/markFailed/releaseLease(MessageId)  → void
                    └─ [신세대] claimBatch → List<OutboxLease>
                                 markPublished/markAmbiguous/markExhausted/markFailed/releaseLease(OutboxLease)
                                    → OutboxTransitionResult {APPLIED, STALE_LEASE}
  OutboxStatus {PENDING, IN_FLIGHT, PUBLISHED, AMBIGUOUS, FAILED, EXHAUSTED}

Inbox
  IdempotentMessageHandler.handleOnce(consumerName, delivery, TransactionalMessageAction)
  InboxRepository.reserve(messageId, consumerId, now) → boolean
  InboxRecord (messageId + consumerId + processedAt)   ← 참조 0
  InboxResult {APPLIED, ALREADY_APPLIED, CLAIMED_ELSEWHERE}

Claim Check
  ClaimCheckReference (storageKey, sizeBytes, sha256, expiresAt)

4. 계약·불변식·상태 모델

4.1 OutboxLease — fencing token

이 leaf에서 가장 중요한 안전 장치이고, 이전 결함이 javadoc에 통째로 있다.

// OutboxLease.java:8-16
 * <p>The port used to take a {@code MessageId} for every terminal transition, so a write said which
 * row to change and nothing about which claim it belonged to. A relay that stalled past its lease
 * could still record {@code AMBIGUOUS} over the {@code PUBLISHED} another relay had already
 * written, and the row became claimable again  one message, published twice, by a system whose
 * whole purpose is to publish it once.
 *
 * <p>The token is the part that makes staleness detectable. It increases on every claim, so a
 * superseded relay holds a number the row no longer has and its update matches zero rows.

token < 1을 거절하는 이유도 적혀 있다 — "a claim's token starts at 1; 0 is the value of a row nobody has claimed".

expiredAt(now)!now.isBefore(expiresAt)다.

4.2 OutboxTransitionResult — void가 삼킨 것
// :5-9
 * <p>The transitions returned {@code void}, so an update that matched zero rows was
 * indistinguishable from one that matched one. That is precisely the stale-lease case: the relay
 * believes it recorded the outcome, the row still says something else, and nothing anywhere counts
 * the disagreement.

두 값이고 STALE_LEASE의 javadoc이 운영 의미까지 적는다.

 * <p>Another relay claimed it after the lease expired. Not an error to throw  the message is
 * being handled by somebody else  but never a success either: it is the signal that this
 * worker's publish attempt may have produced a duplicate, and it belongs on a metric.

"belongs on a metric" — 그 메트릭이 존재하는지는 outbox leaf가 답한다.

4.3 OutboxStatus — 여섯 상태와 두 개의 구분

PENDINGIN_FLIGHTPUBLISHED / AMBIGUOUS / FAILED / EXHAUSTED.

두 쌍의 구분이 각각 이유를 갖는다.

AMBIGUOUS vs FAILED:

// :6-9
 * <p>{@link #AMBIGUOUS} is a distinct state rather than a flavour of failure. A record whose
 * publish timed out may already be on the broker; retrying it is correct, but only under the same
 * logical message id, and an operator looking at the table needs to be able to tell those rows
 * apart from ones that definitely never landed.

EXHAUSTED vs FAILED:

// :31-34
 * <p>Distinct from {@link #FAILED}, which means the broker refused the message: this one means
 * nobody ever got an answer. Collapsing the two loses the difference between "this message is
 * invalid" and "the broker was unreachable for an hour", and those need different operator
 * actions  the first a fix, the second a redrive.

OutboxRepository.markExhausted의 javadoc이 같은 말을 반복한다 — "The first needs a fix, the second a redrive."

FAILED의 의미가 애플리케이션 쪽 동명 enum과 반대다. CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM.because(...)가 그것을 ArchUnit 규칙의 근거로 든다 — "its OutboxStatus.FAILED means the opposite of the legacy OutboxEventStatus.FAILED, so the two models cannot be mixed by name without inverting retryable and terminal." 즉 이 enum의 의미가 저장소 규칙 하나의 존재 이유다.

4.4 InboxResult — 두 개가 아니라 세 개
// :6-9
 * <p>Three outcomes, not two. Collapsing {@link #ALREADY_APPLIED} and {@link #CLAIMED_ELSEWHERE}
 * into a single "duplicate" would settle a message whose effect is still only half-written by
 * another instance: if that instance then rolls back, the effect is lost and the broker will never
 * redeliver, because this instance already acknowledged it.

safeToSettle 플래그가 상수에 붙어 있다.

safeToSettle
APPLIED true 이 트랜잭션에서 효과 실행
ALREADY_APPLIED true 커밋된 예약 존재 — 이미 실행됨
CLAIMED_ELSEWHERE false 다른 인스턴스가 미커밋 예약 보유

세 번째의 javadoc이 결론을 적는다 — "Do not settle. The other transaction may still roll back, and this delivery is the only remaining copy that could re-apply the effect."

세 값 모두 필요한 이유가 명확하고, isSafeToSettle()이 그 판단을 하나로 모은다.

4.5 InboxRepository — 키가 (message, consumer)다
// InboxRecord.java:9-12
 * <p>Keyed by message id <em>and</em> consumer id, because two independent consumers of the same
 * event must each process it once  deduplicating on the message alone would let the first consumer
 * suppress the second.

IdempotentMessageHandler의 javadoc이 같은 이유를 API 형태로 반복한다 — consumerName이 파라미터인 이유.

purgeProcessedBefore의 javadoc이 보존 기간 규칙을 적는다.

 * <p>Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery
 * arrives after its inbox row was pruned and is processed a second time.

이 규칙을 강제하는 코드가 없다. 보존 기간과 브로커 재전달 창을 비교하는 검증이 이 leaf에도, messaging-policy의 프로파일 검증기에도 없다. §17.

4.6 TransactionalMessageAction — 트랜잭션 경계의 소유권
// :8-16
 * <p>Sharing one transaction is the entire mechanism. If the effect committed separately from the
 * "I have handled this message" marker, a crash between the two would either replay the effect or
 * suppress a message that was never handled  and which of those you get would depend on the order
 * the two commits happened to be written in.
 *
 * <p>Implementations must not settle the message, publish, or start their own transaction. The
 * runtime owns the transaction boundary precisely so that the action cannot accidentally commit
 * half of it.

세 금지("settle하지 마라, publish하지 마라, 자기 트랜잭션을 시작하지 마라")가 문서로만 표현된다. 함수형 인터페이스이므로 타입이 강제할 수 없다. §17.

4.7 OutboxCanonicalMetadata — 컬럼이어야 하는 이유

이 leaf에서 가장 긴 javadoc이고, 이전 결함과 설계 대안을 함께 적는다.

// :14-28
 * <p>They used to live nowhere. A row held identity, type, version, content type, payload and an
 * arbitrary header map, so producer, tenant, correlation, causation, trace and schema were either
 * invented when the envelope was rebuilt  {@code Optional.empty()} for every one of them  or
 * smuggled through the header map under reserved names the platform was supposed to own.
 *
 * <p>Both routes fail in the same direction. A relay cannot filter, route or diagnose by tenant
 * without decoding the payload, so the operational question "which tenant is backed up" has no
 * answer; and a message that crossed the outbox arrived at its consumer with a different tenant,
 * trace and correlation than the one that was published, which makes the publish path  direct,
 * polling or CDC  part of the message's meaning.
 *
 * <p>Columns rather than a blob, because the point is that the database can answer questions about
 * them. A versioned envelope encoding would round-trip just as faithfully and would still leave the
 * relay unable to select rows for one tenant.

세 번째 문단이 고려된 대안을 명시적으로 기각한다 — 버전 있는 봉투 인코딩이 왕복 충실도는 같지만 테넌트별 조회를 못 한다는 것. 이 저장소에서 대안을 이름 붙여 기각한 드문 예다.

불변식 하나: schemaUri.isPresent() && schemaSubject.isEmpty()를 거절한다 — "a reader would have a URI and no way to know what it is a schema for".

traceContextOptional이 아니고 TraceContext.none()이라는 자체 빈 형태를 갖는다. javadoc이 그 이유를 적는다 — 컬럼이 생기기 전에 쓰인 행과, 진짜로 correlation이 없는 행을 구분할 필요가 없다는 것("the reader's behaviour is the same: carry what is there and invent nothing").

4.8 OutboxRecord — 두 반쪽의 소유자가 다르다
// :26-29
 * <p>{@link OutboxCanonicalMetadata} is a separate component rather than more fields here because
 * the two halves answer to different owners. Identity, payload, status, attempts and lease are the
 * relay's bookkeeping; the metadata is the message's own provenance, and it is the half that has to
 * survive the round trip through the database unchanged.

payload가 양방향 방어 복사(payload.clone() 생성 시와 접근 시), headersMap.copyOfmessaging-schema-apiEncodedMessage(그쪽 §4.3)와 같은 패턴이다.

withStatusmessageId를 파라미터로 받지 않는다 — "The message id is never a parameter, so no state transition can change it." 타입이 불변식을 강제하는 예다.

equals/hashCode다섯 필드 중 넷만 본다 — messageId, status, attempts, payload. destination·metadata·createdAt·leaseExpiresAt·lastFailureCode는 비교하지 않는다. record 기본 동작을 의도적으로 좁혔는데 그 이유가 어디에도 적혀 있지 않다. §17.

toString이 payload를 담지 않는다.

4.9 ClaimCheckReference — digest가 선택이 아니다
// :10-16
 * <p>The digest is part of the reference, not an optional extra. A claim check splits a message
 * into two systems with independent retention and replication, so a consumer that fetches the
 * payload has to be able to prove it got the bytes the producer stored  otherwise a truncated or
 * replaced object is indistinguishable from a valid one.
 *
 * <p>The expiry is carried for the same reason: a claim check whose payload has been reaped is a
 * dead message, and detecting that at fetch time is better than a mysterious not-found.

sha256[a-f0-9]{64} 정확 일치다 — 대문자 hex를 거절한다. messaging-core-apiTraceContext가 대문자 traceparent를 거절하는 것(그쪽 §4.11)과 같은 규율이지만, 여기서는 그 이유가 적혀 있지 않다.

expiresAtOptional이 아니다 — 모든 claim check가 만료를 갖는다.


5. 주요 실행 경로

Outbox 쓰기: 애플리케이션 트랜잭션 안에서 ReliableMessagePublisher.addToOutbox(...)OutboxRepository.append(record)진입점 구현이 없다(§12.1)

Outbox 릴레이: claimBatch(owner, size, lease, now, maxAttempts)List<OutboxLease> → 각 lease에 대해 발행 → 결과에 따라 markPublished/markAmbiguous/markExhausted/markFailed(lease 기반) → APPLIED면 정상, STALE_LEASE면 다른 릴레이가 가져감

Inbox: handleOnce(consumerName, delivery, action) → 한 트랜잭션 안에서 reserve(messageId, consumerId, now) → true면 action.apply(delivery) → 커밋


6. 실패 경로와 복구/번역

이 leaf는 MessagingException을 하나도 던지지 않는다. 실패를 상태와 반환값으로 표현한다.

표현
릴레이 전이 결과 OutboxTransitionResult.{APPLIED, STALE_LEASE}
Outbox 행 상태 OutboxStatus 6개
Inbox 판정 InboxResult 3개 + isSafeToSettle()
claim check 만료 ClaimCheckReference.isExpired(now)
lease 만료 OutboxLease.expiredAt(now)

IllegalArgumentException을 던지는 곳은 record 생성자 여섯이다 — 전부 호출자의 프로그래밍 오류다.

TransactionalMessageAction.applythrows Exception이다 — javadoc: "rolling back both it and the inbox reservation". 즉 예외가 롤백 신호이고, 그 처리는 구현 leaf가 소유한다.


7. 트랜잭션·동시성·수명주기

이 leaf 전체가 트랜잭션 계약이다. 그런데 코드에는 트랜잭션이 없다 — 전부 javadoc이 요구하는 규약이다.

계약 표현 위치 강제
OutboxRepository.append가 호출자 트랜잭션 안 인터페이스 javadoc 없음
나머지 메서드는 릴레이 자기 트랜잭션 같은 javadoc 없음
InboxRepository.reserve가 핸들러 부작용과 같은 트랜잭션 인터페이스 javadoc 없음
TransactionalMessageAction이 자기 트랜잭션을 시작하지 않음 javadoc 없음
ReliableMessagePublisher.addToOutboxvoid인 것 javadoc 타입이 강제

마지막 하나만 타입이 강제한다.

// ReliableMessagePublisher.java:9-12
 * <p>The return type is {@code void}, and that is the contract. There is no publish outcome to
 * report yet: the row is written inside the caller's transaction, so if the transaction rolls back
 * the message never existed, and if it commits the relay will publish it later. Handing back a
 * {@code PublishResult} here would be a lie about work that has not happened.

동시성 원시 요소는 하나 — fencing token. 그것이 OutboxLease.token이고 검사는 구현의 SQL WHERE에 있다(§12.1).

모든 record가 불변이다. 상태를 가진 클래스가 하나도 없다.

수명주기 참여 없음.


8. 설정·기능 플래그·환경 차이

설정 없음. 상수도 없다 — ClaimCheckReference.SHA256 정규식 하나가 private이다.

OutboxRepository의 두 purge* 메서드가 limit 파라미터를 갖는 것이 유일한 튜닝 지점이고, 그 이유가 javadoc에 있다.

// :143-147
 * <p>The unbounded version deletes everything before the cutoff in one statement. On a table that
 * has been accumulating published rows since the last sweep that is a single long transaction
 * holding locks and generating WAL in proportion to the backlog, which shows up as the relay and
 * the business writes stalling behind retention. The cleanup jobs describe themselves as bounded
 * by batch size; this is the parameter that makes that true.

InboxRepository도 같은 쌍을 갖는다.


9. 퍼시스턴스/외부 시스템 세부

없다 — 포트만 정의한다. 다만 포트가 저장소 기술을 전제한다.

  • InboxRepository.reserve의 메커니즘이 "the uniqueness constraint on the inbox row"다 — 유니크 제약이 있는 저장소를 전제
  • OutboxRepository.claimBatch의 의미가 "a record claimed by one relay is invisible to the others"다 — 행 잠금 또는 그에 준하는 것을 전제
  • OutboxTransitionResult.STALE_LEASE가 "its update matches zero rows"에서 나온다 — 조건부 UPDATE의 영향 행 수를 셀 수 있는 저장소를 전제

세 전제 모두 javadoc에 있고 인터페이스 이름에는 없다. 구현 leaf 이름(*-jdbc-postgresql)이 실제 선택을 드러낸다.


10. 테스트 레인과 실제 증명 범위

이 leaf에는 테스트가 없다. src/test 디렉터리 자체가 존재하지 않는다 — src 아래에 main만 있다.

13개 타입 중 record 생성자 검증이 있는 것이 여섯(ClaimCheckReference, InboxRecord, OutboxCanonicalMetadata, OutboxLease, OutboxRecord, OutboxTransitionResult는 enum), 술어가 있는 것이 셋(isExpired, expiredAt, isSafeToSettle)이다. 그중 어느 것도 이 leaf의 레인에서 검증되지 않는다.

검증은 전부 구현 leaf에서 일어난다.

검증 위치 무엇을
messaging-outbox-jdbc-postgresql 테스트 4개 OutboxRepository 구현, 릴레이
messaging-inbox-jdbc-postgresql 테스트 4개 InboxRepository 구현, 멱등 핸들러
messaging-claim-check 테스트 3개 claim check
starter MessagingOutboxRelayLifecycleTest 릴레이 수명주기

그 결과 이 leaf의 계약 불변식(예: OutboxCanonicalMetadataschemaUri 없이 schemaSubject 금지, OutboxLeasetoken >= 1, InboxResult.isSafeToSettle의 세 값)은 구현이 우연히 그 경로를 지나갈 때만 실행된다.

그리고 §12.1(c)가 보이듯, 실제 PostgreSQL 컨테이너 테스트는 production이 쓰지 않는 API 세대를 검증한다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api"]
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 벤더 의존성 0
APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM ..application..이 이 leaf를 포함한 dev.caskeleton.messaging..을 참조하는 것을 금지. 규칙의 근거가 이 leaf의 OutboxStatus.FAILED 의미다
SecretLeakStaticScanTest(observability leaf) 이 leaf 소스도 스캔 대상
ArchUnit 전용 규칙 없음

네 번째가 특이하다 — ArchUnit 규칙 하나가 이 leaf의 enum 상수 의미를 근거로 든다. 즉 이 leaf의 어휘가 저장소 경계 규칙의 일부다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/289-reliability-api-two-generations.txt.

12.1 Public surface reachability
타입 leaf 밖 파일 판정
OutboxRecord 13 활발
OutboxCanonicalMetadata 8 활발
OutboxRepository 7 구현 1 + 릴레이 + 테스트
OutboxStatus 7 활발
OutboxLease 6 활발
OutboxTransitionResult 6 활발
InboxRepository 6 구현 1 + 테스트
ClaimCheckReference 6 활발
InboxResult 2
IdempotentMessageHandler 1 TransactionalInboxHandler
TransactionalMessageAction 1 같음
InboxRecord 0
ReliableMessagePublisher 0

(a) Outbox 쓰기 진입점에 구현이 없다

ReliableMessagePublisher는 애플리케이션이 outbox에 행을 넣는 유일한 선언된 방법이다. 구현이 0이고 참조도 0이다.

OutboxRepository.append는 존재하지만 그것은 저장소 포트다 — javadoc이 "must be callable inside the caller's business transaction"이라고 하므로 애플리케이션이 직접 부를 수도 있다. 그러나 ReliableMessagePublisher가 존재하는 이유는 애플리케이션이 저장소 포트를 직접 만지지 않게 하는 것이고, 그 층이 비어 있다.

그리고 애플리케이션은 이 leaf를 참조할 수 없다APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM이 금지한다. 즉 ReliableMessagePublisher를 애플리케이션이 쓰려면 브리지 어댑터가 필요하고, 그 어댑터가 없다. messaging-spring-cloud-stream-bridge가 후보 이름이지만 그 leaf는 runtime_memberships: []다.

(b) InboxRecord가 쓰이지 않는다

InboxRepository의 어느 메서드도 InboxRecord를 주고받지 않는다 — reserveboolean, isProcessedboolean, purge*int다. record는 "One row of the consumer inbox"를 서술하지만 그 행을 반환하는 API가 없다.

같은 leaf의 OutboxRecord는 정반대다 — leaseBatch/find가 반환하고 13개 파일이 쓴다. 두 record의 역할이 비대칭이다.

(c) 컨테이너 테스트가 production이 쓰지 않는 API 세대를 검증한다

OutboxRepository는 같은 다섯 전이에 대해 두 세대를 갖는다.

전이 구세대 (MessageId) 신세대 (OutboxLease)
배치 획득 leaseBatch(size, lease, now)List<OutboxRecord> claimBatch(owner, size, lease, now[, maxAttempts])List<OutboxLease>
발행 확정 markPublished(MessageId, Instant)void markPublished(OutboxLease, Instant)OutboxTransitionResult
모호 markAmbiguous(MessageId, String, Instant)void markAmbiguous(OutboxLease, ...)OutboxTransitionResult
실패 markFailed(MessageId, String, Instant)void markFailed(OutboxLease, ...)OutboxTransitionResult
반납 releaseLease(MessageId)void releaseLease(OutboxLease)OutboxTransitionResult
소진 markExhausted(OutboxLease, String, Instant)

production 릴레이는 신세대만 쓴다.

OutboxRelay.java:158  repository.claimBatch(owner, batchSize, leaseDuration, now, scheduler.maxAttempts())
OutboxRelay.java:171  repository.markPublished(lease, now) == OutboxTransitionResult.APPLIED
OutboxRelay.java:189  repository.markExhausted(lease, reason, now)
OutboxRelay.java:192  repository.markAmbiguous(...)
OutboxRelay.java:205  repository.markFailed(...)

실제 PostgreSQL 컨테이너 테스트는 구세대만 쓴다.

OutboxPostgresIT.java:92,111,112,121,124,133,148,161  repository.leaseBatch(...)
OutboxPostgresIT.java:135  repository.markAmbiguous(record.messageId(), "CONFIRM_TIMEOUT", NOW)
OutboxPostgresIT.java:150,200  repository.markPublished(record.messageId(), NOW)
OutboxPostgresIT.java:163  repository.markFailed(record.messageId(), "INVALID_TOPIC", NOW)

fencing token 경로가 실제 데이터베이스에 대해 한 번도 실행되지 않는다. 그 경로의 정확성은 구현의 SQL WHERE ... AND token = ?이 영향 행 수를 정확히 세는지에 달려 있는데, 그것을 검증할 수 있는 유일한 레인이 다른 세대를 쓴다. 나머지 검증은 InMemoryOutboxRepository(OutboxRelayTest:223)와 RecordingRepository(OutboxOperationsTest:23) — 둘 다 SQL이 없는 fake다.

OutboxLease javadoc이 fencing token을 만든 이유로 든 사고("one message, published twice")가 정확히 그 SQL이 막는 것이다.

이 판정의 소유권. API 형태(두 세대 공존, @Deprecated 부재)는 이 leaf가 소유하고, 테스트 커버리지 판정은 messaging-outbox-jdbc-postgresql leaf가 소유한다. 여기서는 관측과 교차 참조를 남긴다.

(d) 구세대가 prose로만 deprecated다

// OutboxRepository.java:41-43
 * <p>The token is what a terminal write is checked against. {@link #leaseBatch} returns records
 * without one, so its callers cannot prove a write belongs to their claim; it remains for
 * inspection paths and is deprecated for the relay's use.

@Deprecated 애노테이션이 이 leaf 전체에 하나도 없다(git grep '@Deprecated' -- src/messaging/messaging-reliability-api exit 1).

결과: 새 구현자가 17개 메서드를 전부 구현해야 하고, 그중 다섯은 fencing이 없는 형태다. 컴파일러가 경고하지 않으므로 새 호출자가 구세대를 고를 수 있고, 실제로 컨테이너 테스트가 그렇게 했다.

(e) bounded purge 오버로드가 두 포트에 선언·구현돼 있고 호출 지점이 0이다

이 항목은 messaging-inbox-jdbc-postgresql 분석 중에 확인됐다. 이 문서의 초판은 §17의 "확인된 설계"에 "purge에 limit 파라미터를 둔 것"을 넣었는데, 그것은 파라미터의 존재만 본 판정이었다. 호출 여부를 재측정해 정정한다.

InboxRepository.purgeProcessedBefore(Instant, int)OutboxRepository.purgePublishedBefore(Instant, int)가 선언돼 있고 두 JDBC 구현이 LIMIT(inbox는 FOR UPDATE SKIP LOCKED까지)로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 선언 2 + 구현 2 + 테스트 fake override 5이고 호출 지점이 하나도 없다. 두 cleanup job이 무제한 오버로드를 부른다 — InboxCleanupJob:56, OutboxCleanupJob:50.

OutboxRepository:140-151의 javadoc이 그 상황을 예고한다.

The unbounded version deletes everything before the cutoff in one statement. … which shows up as the relay and the business writes stalling behind retention. The cleanup jobs describe themselves as bounded by batch size; this is the parameter that makes that true.

그 파라미터를 아무도 넘기지 않는다. 판정은 §A19-MESSAGING-INBOX-JDBC-POSTGRESQL §17(P1)이 소유하고, 이 문서는 포트가 두 오버로드를 나란히 노출했다는 것을 기여한다 — (a)의 두 세대 전이와 같은 형태다.

12.2 Conditional sibling comparison

이 leaf에 bean은 없다. 구현 leaf 셋의 sibling 비교가 유의미하다.

포트 구현 leaf membership starter bean
OutboxRepository messaging-outbox-jdbc-postgresql ["app-bootstrap"] MessagingReliabilityAutoConfiguration
InboxRepository messaging-inbox-jdbc-postgresql ["app-bootstrap"] 같음
IdempotentMessageHandler messaging-inbox-jdbc-postgresql 같음 transactionalInboxHandler bean
ReliableMessagePublisher 없음

네 포트 중 셋이 구현·편입·조립을 모두 갖고 하나가 셋 다 없다. 비대칭이 명확하다.

12.3 Duplicate mechanism sweep

(a) 같은 전이의 두 세대 — §12.1(c). 한 인터페이스 안의 중복이라는 점에서 이 저장소의 다른 중복(두 클래스, 두 leaf)과 형태가 다르다.

(b) outbox 개념이 저장소에 둘 있다

이 leaf application-core
상태 enum OutboxStatus OutboxEventStatus
FAILED의 뜻 브로커가 확정적으로 거절 — 재시도 안 함 (반대 의미, ArchUnit javadoc이 명시)
행 타입 OutboxRecord NewOutboxEvent
사용처 messaging family application + persistence-jpa

의도된 분리다. ArchUnit 규칙이 둘을 섞지 못하게 하고, 그 규칙의 .because(...)가 이유를 적는다 — "the two outbox status models mean opposite things under the same names". 중복 경쟁이 아니라 명시적으로 격리된 두 모델이다.

다만 그 결과 ReliableMessagePublisher가 쓰일 자리가 없다(§12.1a) — 애플리케이션은 자기 outbox 모델을 쓰고, 이 leaf의 진입점은 브리지 없이는 도달 불가다.

(c) 이름 충돌 주의

markPublished·markFailed·releaseLease라는 메서드 이름이 저장소의 완전히 다른 인터페이스 여러 곳에 있다 — persistence-jpaOutboxStoreAdapter·JpaCleanupQueue·JpaUploadSessionStore, cache-redisRedisIdempotencyStoreAdapter, notificationJpaProviderEventLedger. 단어 검색으로 이 leaf의 사용처를 세면 오탐이 대량 발생한다. §12.1(c)의 측정은 src/messaging/**로 범위를 좁혀 얻은 것이다.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
OutboxRepository:43: leaseBatch가 "deprecated for the relay's use" @Deprecated 0건, 컨테이너 테스트가 사용 미강제
OutboxRecord javadoc: outbox만으로는 중복 제거 안 됨 InboxRepository가 별도 존재 일치
InboxRepository.purge* javadoc: 보존이 브로커 재전달 창보다 길어야 함 그 비교를 하는 코드 없음 미강제
TransactionalMessageAction javadoc: 구현이 settle/publish/트랜잭션 시작 금지 타입이 강제하지 않음 미강제
ReliableMessagePublisher javadoc: dual-write의 답 구현 0 미실현
OutboxTransitionResult.STALE_LEASE javadoc: "it belongs on a metric" 이 leaf에 메트릭 없음. outbox leaf가 답함 미확인
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift)

13. Git/설계 문서에서 확인한 변화와 실패 기록

이 leaf의 javadoc은 세 개의 서로 다른 결함을 보존한다.

위치 이전 상태 그것이 만든 실패
OutboxLease javadoc 모든 terminal 전이가 MessageId만 받음 lease를 넘긴 릴레이가 다른 릴레이의 PUBLISHED 위에 AMBIGUOUS를 기록 → 행이 다시 claim 가능해짐 → 한 메시지가 두 번 발행됨, 한 번만 발행하는 것이 목적인 시스템에서
OutboxTransitionResult javadoc 전이가 void 반환 0행 매치와 1행 매치가 구별 불가 → 릴레이는 기록했다고 믿고 행은 다른 상태이며 그 불일치를 아무도 세지 않음
OutboxCanonicalMetadata javadoc provenance가 어디에도 없음 봉투 재구성 시 producer·tenant·correlation·causation·trace·schema가 전부 Optional.empty()가 되거나 헤더 맵에 예약 이름으로 밀반입 → outbox를 지난 메시지가 다른 tenant·trace·correlation으로 도착, 즉 발행 경로가 메시지의 의미의 일부가 됨
OutboxRepository.purgePublishedBefore javadoc 무제한 삭제 백로그에 비례하는 단일 긴 트랜잭션이 락과 WAL을 생성 → 릴레이와 업무 쓰기가 보존 작업 뒤에서 멈춤

첫 둘이 같은 사건의 두 측면이다 — fencing token(감지 수단)과 반환값(감지 결과의 전달 수단). 둘 다 있어야 stale lease가 관측된다.

세 번째의 마지막 문장이 이 저장소에서 가장 날카로운 진술 중 하나다 — "which makes the publish path — direct, polling or CDC — part of the message's meaning." 전달 경로가 메시지 내용을 바꾸면 그것은 더 이상 전달이 아니다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-294 command evidence/raw/294-bounded-purge-never-called.txt bounded 오버로드의 호출 지점 0, 두 cleanup job의 실제 호출 정적 검색. messaging-inbox-jdbc-postgresql이 판정 소유
EVD-289 command evidence/raw/289-reliability-api-two-generations.txt src/test 부재, 13타입 정규화 참조 수, 소비자 0인 둘, 네 포트의 구현자, OutboxRepository의 두 세대 시그니처 전수, @Deprecated 0건, production 릴레이와 컨테이너 테스트가 쓰는 세대, ArchUnit 규칙의 근거 문구 정적 검색. 이 leaf에 실행할 테스트 레인이 없음

이 leaf에는 test lane evidence가 없다src/test가 존재하지 않으므로 :messaging:messaging-reliability-api:test는 실행할 소스가 없다.


15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • outbox만으로 중복이 제거되지 않는 이유 — OutboxRecord javadoc
  • fencing token이 필요한 이유와 이전 이중 발행 — OutboxLease javadoc
  • 전이가 결과를 반환해야 하는 이유 — OutboxTransitionResult javadoc
  • AMBIGUOUS가 실패의 한 종류가 아닌 이유, EXHAUSTEDFAILED와 다른 이유 — OutboxStatus javadoc
  • provenance가 컬럼이어야 하는 이유와 기각된 대안(버전 봉투 인코딩) — OutboxCanonicalMetadata javadoc
  • 두 반쪽의 소유자가 다른 이유 — OutboxRecord javadoc
  • inbox 키가 (message, consumer)인 이유 — InboxRecord·IdempotentMessageHandler javadoc
  • InboxResult가 셋인 이유 — 그 javadoc
  • 예약이 부작용과 같은 트랜잭션이어야 하는 이유 — InboxRepository·TransactionalMessageAction javadoc
  • addToOutboxvoid인 이유 — ReliableMessagePublisher javadoc
  • claim check digest와 만료가 필수인 이유 — ClaimCheckReference javadoc
  • purge에 limit이 필요한 이유 — OutboxRepository javadoc
  • inbox 보존이 재전달 창보다 길어야 하는 이유 — InboxRepository javadoc

추론

  • ReliableMessagePublisher 구현이 없는 것은 애플리케이션이 자기 outbox 모델을 쓰고 브리지가 없기 때문이다 → 추론. ArchUnit 금지와 두 모델의 공존은 관측이고 인과는 추론이다.
  • OutboxRecord.equals가 다섯 필드만 보는 이유 → 미상.
  • sha256이 소문자만 받는 이유 → 미상(다른 곳의 같은 규율에서 유추 가능하나 여기엔 없음).
  • 구세대를 남긴 이유 → 부분 명시("remains for inspection paths"). 제거 시점은 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 13개 타입 817줄 전문의 계약과 불변식
  • 이 leaf에 테스트가 하나도 없다는 것(src/test 부재)
  • ReliableMessagePublisherInboxRecord의 참조 0
  • OutboxRepository가 같은 다섯 전이의 두 세대를 갖고 @Deprecated가 하나도 없다는 것
  • production 릴레이가 신세대만, PostgreSQL 컨테이너 테스트가 구세대만 쓴다는 것
  • 세 개의 이전 결함(fencing 부재, void 반환, provenance 부재)과 각각의 실패 형태
  • OutboxStatus.FAILED의 의미가 저장소 ArchUnit 규칙의 근거라는 것

확인하지 못한 것

  • fencing token SQL이 실제 PostgreSQL에서 정확한지. 그것을 검증할 레인이 다른 세대를 쓴다. messaging-outbox-jdbc-postgresql leaf가 이 판정을 소유한다.
  • STALE_LEASE가 실제로 메트릭으로 나가는지 — 같은 leaf가 답한다.
  • inbox 보존 기간이 실제 배포에서 브로커 재전달 창보다 긴지 — 비교하는 코드가 없다.
  • ReliableMessagePublisher를 구현할 계획이 있는지, 아니면 애플리케이션 outbox 모델이 정본인지.
  • OutboxRecord.equals의 좁은 비교가 어떤 코드에 의존되는지 — 컬렉션 연산에서 의미가 달라질 수 있다.

17. 손볼 것

P2 — 한 인터페이스가 같은 전이의 두 세대를 갖고, 안전하지 않은 쪽에 @Deprecated가 없다
  • 사실. OutboxRepository가 다섯 전이 각각에 대해 MessageId 기반(반환 void)과 OutboxLease 기반(반환 OutboxTransitionResult) 두 형태를 선언한다. javadoc이 전자를 "deprecated for the relay's use"라고 부르지만 @Deprecated 애노테이션이 이 leaf 전체에 0건이다.
  • 근거. evidence/raw/289 §E·§F.
  • 왜 문제인가. 전자에는 fencing이 없다 — OutboxLease javadoc이 그 부재가 만든 이중 발행 사고를 기록한다. 컴파일러가 경고하지 않으므로 새 호출자가 그것을 고를 수 있고, 실제로 PostgreSQL 컨테이너 테스트가 그렇게 했다(§12.1c). 그리고 새 구현자는 17개 메서드를 전부 구현해야 하며 그중 다섯은 안전하지 않은 형태다.
  • 확인 방법. git grep -n '@Deprecated' -- src/messaging/messaging-reliability-api → 없음. evidence/raw/289 §E.
  • 후보. (a) 구세대 다섯에 @Deprecated를 붙인다. (b) 검사 경로가 정말 필요하면 별도 인터페이스(OutboxInspection)로 분리한다. (c) 구세대를 제거하고 호출자를 옮긴다.
  • 다음 단계. CASE 후보 + REFERENCE 후보. "prose deprecation은 컴파일러가 읽지 않는다"가 재사용 가능한 기준이다.
P2 — fencing token 경로가 실제 데이터베이스에 대해 실행되지 않는다
  • 사실. OutboxRelayclaimBatch/lease 기반 전이만 쓴다. OutboxPostgresITleaseBatch/MessageId 기반 전이만 쓴다. 신세대를 쓰는 다른 테스트는 InMemoryOutboxRepositoryRecordingRepository — SQL이 없는 fake다.
  • 근거. evidence/raw/289 §G.
  • 왜 문제인가. fencing의 정확성은 구현의 조건부 UPDATE가 영향 행 수를 정확히 세는지에 달려 있다. OutboxTransitionResult.STALE_LEASE는 "its update matches zero rows"에서 나오고, 그것은 SQL의 성질이지 Java의 성질이 아니다. in-memory fake는 그 SQL을 실행하지 않는다. 즉 이중 발행을 막는 장치가 그것을 검증할 수 있는 유일한 환경에서 실행되지 않는다.
  • 확인 방법. evidence/raw/289 §G 재실행. OutboxPostgresIT에서 claimBatch 검색 → 없음.
  • 후보. 컨테이너 테스트를 신세대로 옮기고, stale lease 시나리오(두 릴레이, 만료 후 재claim)를 실제 DB에서 재현한다.
  • 다음 단계. 판정은 messaging-outbox-jdbc-postgresql leaf가 소유한다. 여기서는 API 형태가 그 혼동을 가능하게 했다는 관측을 기여한다. CASE 후보(그 leaf).
P2 — dual-write의 답이라고 선언한 진입점에 구현이 없다
  • 사실. ReliableMessagePublisher가 구현 0, 참조 0이다. javadoc은 "This is the answer to the dual-write problem"이라고 한다.
  • 근거. evidence/raw/289 §B·§C·§D.
  • 왜 문제인가. OutboxRepository.append가 있으므로 outbox에 행을 넣을 방법이 없는 것은 아니다. 그러나 그 포트는 저장소 계약이고, ReliableMessagePublisher는 애플리케이션이 저장소를 직접 만지지 않게 하려고 존재한다. 그리고 애플리케이션은 ArchUnit 규칙 때문에 이 leaf를 참조할 수 없으므로 브리지 어댑터가 필요한데 그것이 없다. 즉 이 leaf의 Outbox 절반은 "릴레이가 읽는 쪽"만 배선돼 있고 "애플리케이션이 쓰는 쪽"이 비어 있다.
  • 확인 방법. git grep -n -E 'implements .*ReliableMessagePublisher' -- src → 없음.
  • 후보. (a) 브리지 어댑터를 만든다. (b) 애플리케이션 outbox 모델이 정본이면 이 인터페이스를 제거하거나 "파생 프로젝트가 구현하는 확장점"임을 명시한다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 "두 outbox 모델 중 어느 쪽이 정본인가"에 걸리고, 그 질문은 application-core와 cross-scope가 함께 답한다.
P3 — 이 leaf에 테스트가 없다
  • 사실. src/test 디렉터리가 존재하지 않는다. 13개 타입의 record 생성자 검증 여섯과 술어 셋이 이 leaf의 레인에서 실행되지 않는다.
  • 근거. evidence/raw/289 §A.
  • 왜 문제인가. 계약 불변식 중 일부는 구현이 우연히 지나가지 않으면 실행되지 않는다 — 예: OutboxCanonicalMetadataschemaUri 있고 schemaSubject 없는 조합을 거절하는 것, OutboxLeasetoken < 1을 거절하는 것, InboxResult.isSafeToSettle의 세 값. 형제 leaf들은 전부 자기 테스트를 갖는다(messaging-core-api 79개, messaging-policy 42개 등).
  • 확인 방법. ls src/messaging/messaging-reliability-api/srcmain만.
  • 후보. record 불변식과 세 술어를 겨냥한 단위 테스트를 추가한다.
  • 다음 단계. REFERENCE 후보(계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다).
P3 — inbox 보존 규칙이 문서로만 있다
  • 사실. InboxRepository.purgeProcessedBefore javadoc이 "Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery arrives after its inbox row was pruned and is processed a second time"라고 한다. 그 비교를 하는 코드가 이 leaf에도 messaging-policy의 프로파일 검증기에도 없다.
  • 근거. 해당 javadoc. DestinationProfileValidator 16규칙 전수(재전달 창 관련 없음).
  • 왜 문제인가. 위반의 결과가 부작용의 이중 실행이다 — Inbox가 존재하는 이유 그 자체가 무효화된다. 그리고 위반이 조용하다: 짧은 보존은 정상 동작처럼 보이고 늦은 재전달이 올 때만 드러난다.
  • 확인 방법. git grep -n -i 'redelivery window\|retention' -- 'src/messaging/**/*.java'
  • 후보. 보존 설정과 브로커 재전달 창을 시작 시 비교하는 검증을 messaging-policy나 starter에 추가한다.
  • 다음 단계. CASE 후보 + REFERENCE 후보(두 시간 상수가 순서 관계를 가지면 그 관계를 시작 시 검사한다).
P3 — 트랜잭션 계약 셋이 타입으로 강제되지 않는다
  • 사실. OutboxRepository.append가 호출자 트랜잭션 안, InboxRepository.reserve가 부작용과 같은 트랜잭션, TransactionalMessageAction이 자기 트랜잭션을 시작하지 않을 것 — 셋 다 javadoc 요구다.
  • 근거. 세 javadoc.
  • 왜 문제인가. ReliableMessagePublishervoid 반환으로 계약의 일부를 타입에 담았다("Handing back a PublishResult here would be a lie"). 나머지 셋에는 그런 장치가 없고, 위반의 결과가 조용하다 — InboxRepository.reserve를 별도 트랜잭션에서 부르면 "exactly the gap the Inbox exists to close"가 다시 열린다.
  • 확인 방법. 세 javadoc과 구현의 @Transactional 배치 대조 — 구현 leaf가 소유한다.
  • 후보. 구현 leaf가 트랜잭션 참여를 검증하는 테스트를 두거나, ArchUnit으로 append/reserve 호출부의 트랜잭션 컨텍스트를 검사한다.
  • 다음 단계. REFERENCE 후보(호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다).
P3 — OutboxRecord.equals가 다섯 필드만 비교하고 이유가 없다
  • 사실. equals/hashCodemessageId·status·attempts·payload 넷만 본다. destination·metadata·createdAt·leaseExpiresAt·lastFailureCode는 무시한다.
  • 근거. OutboxRecord.java:114-126.
  • 왜 문제인가. record 기본 동작을 좁힌 것이고, 배열 필드 때문에 재정의가 필요한 것까지는 명확하다(messaging-schema-apiEncodedMessage도 같다). 그러나 EncodedMessage모든 필드를 비교하고 이쪽은 아니다. 같은 messageId·status·attempts·payload를 가진 두 행이 다른 목적지·다른 provenance를 가져도 같다고 판정된다. 컬렉션 연산이나 테스트 단언에서 의미가 달라진다.
  • 확인 방법. 두 record의 equals 대조.
  • 후보. 전 필드 비교로 바꾸거나 좁힌 이유를 javadoc에 적는다.
  • 다음 단계. REFERENCE 후보(record의 equals를 좁히면 이유를 적는다).
P3 — 포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다
  • 사실. InboxRepositoryOutboxRepository가 각각 purge*Before(Instant)purge*Before(Instant, int)를 선언한다. 후자에 호출 지점이 0이고 두 cleanup job이 전자를 부른다.
  • 근거. evidence/raw/294-bounded-purge-never-called.txt.
  • 왜 문제인가. §12.1(a)의 두 세대 전이와 같은 형태다 — 한 인터페이스가 안전한 형태와 그렇지 않은 형태를 나란히 두고, @Deprecated도 이름 차이도 없으며, 호출자가 짧은 쪽을 골랐다. 두 경우 모두 포트의 형태가 오용을 가능하게 했다.
  • 확인 방법. git grep -n -E 'purge(Processed|Published)Before\s*\([^)]*,' -- 'src/**/*.java'
  • 다음 단계. 판정은 §A19-MESSAGING-INBOX-JDBC-POSTGRESQL §17(P1)이 소유한다. 여기서는 포트 형태의 기여만 남긴다. §12.1(a)와 같은 CASE로 묶을 후보다.
확인된 설계(문제 아님)
  • outbox만으로 중복이 제거되지 않는다는 것을 타입 javadoc이 직접 말하는 것
  • fencing token과 전이 결과 반환값이 함께 있어야 stale lease가 관측된다는 설계
  • AMBIGUOUS/FAILED/EXHAUSTED 세 상태의 구분과 각각의 운영 행동 차이
  • InboxResult가 셋이고 isSafeToSettle()이 그 판단을 모으는 것
  • inbox 키가 (message, consumer)인 것
  • provenance를 컬럼으로 두고 대안(버전 봉투 인코딩)을 명시적으로 기각한 것
  • withStatusmessageId를 파라미터로 받지 않아 전이가 신원을 바꿀 수 없는 것
  • addToOutboxvoid 반환이 계약인 것
  • claim check의 digest와 만료가 필수인 것
  • 두 outbox 모델을 ArchUnit으로 격리한 것

Source anchors

id kind path revision what it proves limitations
MRA-001 registry src/config/architecture/modules.json 21234e38 deps 1개, memberships ["app-bootstrap"] 선언
MRA-002 build messaging-reliability-api/build.gradle same 벤더 의존성 0
MRA-003 code .../reliability/OutboxRepository.java 전문 same 두 세대 17메서드, purge limit 이유 @Deprecated 없음
MRA-004 code .../reliability/OutboxLease.java same fencing token과 이중 발행 이력
MRA-005 code .../reliability/OutboxTransitionResult.java same void 반환이 삼킨 것
MRA-006 code .../reliability/OutboxStatus.java same 여섯 상태와 두 구분의 이유
MRA-007 code .../reliability/OutboxCanonicalMetadata.java same provenance 결함 이력, 기각된 대안
MRA-008 code .../reliability/OutboxRecord.java same 두 반쪽 분리, 방어 복사, 좁은 equals equals 이유 없음(§17)
MRA-009 code .../reliability/{InboxRepository,InboxRecord,InboxResult}.java same 트랜잭션 계약, (message,consumer) 키, 세 판정 InboxRecord 참조 0
MRA-010 code .../reliability/{IdempotentMessageHandler,TransactionalMessageAction}.java same 멱등 핸들러 계약과 세 금지 금지 미강제
MRA-011 code .../reliability/{ReliableMessagePublisher,ClaimCheckReference}.java same dual-write 답, digest 필수 publisher 구현 0
MRA-012 cross-leaf code messaging-outbox-jdbc-postgresql/.../OutboxRelay.java:158-205 same production이 신세대만 사용 해당 leaf SSOT가 소유
MRA-013 cross-leaf test messaging-outbox-jdbc-postgresql/.../OutboxPostgresIT.java:92-200 same 컨테이너 테스트가 구세대만 사용 해당 leaf SSOT가 소유
MRA-014 architecture test src/app-bootstrap/.../CleanArchitectureTest.java:229-240 same OutboxStatus.FAILED 의미가 규칙의 근거 정적 분석
EVD-289 command evidence/raw/289-reliability-api-two-generations.txt same §12.1 전부, src/test 부재 정적 검색. 이 leaf에 테스트 레인 없음

A19-MESSAGING-RUNTIME-CORE. messaging-runtime-core

분석 중에는 messaging/MESSAGING-RUNTIME-CORE.md 파일이었다. 807줄.

messaging-runtime-core 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-runtime-core SSOT owner: messaging-runtime-core integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-runtime-core
  • canonical state analysisFile: §A19-MESSAGING-RUNTIME-CORE
  • source path: src/messaging/messaging-runtime-core
  • registry allowed_dependencies: ["messaging-core-api", "messaging-schema-api", "messaging-policy", "messaging-transport-spi", "messaging-security", "messaging-observability"] — messaging family에서 두 번째로 많은 의존
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 6
production LOC 787
패키지 1 (dev.caskeleton.messaging.runtime)
test 파일 4 (테스트 3 + fixture 1)
test 메서드(실행 확인) 21
외부(비프로젝트) 의존성 0

여섯 클래스:

클래스 LOC 역할 출하 조립
DefaultMessagePublisher 366 유일한 발행 경로 o (:446)
DefaultDeliveryProcessor 155 핸들러 결과 → 정산 x
RegisteredMessageCodecs 89 content type → codec o (:363)
DestinationProfileRegistry 62 논리 이름 → 프로파일 o (:377)
TransportMessagingRuntime 67 transport를 세대로 포장 o (:476)
DeclaredDestinationAccess 48 기본 접근 정책 o
Coverage ledger
scope/file group count disposition reason
src/main/java/** (6) 6 FULL_READ 전 파일 본문 확인
src/test/java/** (4) 4 FULL_READ 전 파일 본문 및 단언 확인
build.gradle 1 FULL_READ 주석 포함 17줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

이 leaf는 조립 결함 하나를 고치기 위해 만들어졌다. 여섯 파일 중 다섯의 javadoc이 "X was an interface with no implementation" 형태로 시작한다. build.gradle이 그 사정을 파일 맨 위에 적는다.

// The central publish and delivery orchestration.
//
// MessagePublisher was an interface with no implementation anywhere in the new platform: the
// brokers implemented MessagingTransport, the core auto-configuration built dead-letter and facade
// beans on top of a publisher bean that nothing supplied, and admission, security, runtime leases
// and observation existed as beans that no publish path ever called. A starter that filled the gap
// with an application-supplied fake would pass a context test while running none of them.

이 진단의 마지막 문장이 핵심이다 — 컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립이 가능했다는 것. 이 저장소가 반복해서 만나는 형태다.

six 파일이 메운 구멍:

인터페이스(소유 leaf) 구현이 없었음 이 leaf가 채운 것
MessagePublisher (core-api) 어디에도 없음 DefaultMessagePublisher
MessageCodecRegistry (schema-api) 어디에도 없음 RegisteredMessageCodecs
MessagingRuntime (transport-spi) 어디에도 없음 TransportMessagingRuntime
(없음) 논리이름→프로파일 해석 아무도 하지 않음 DestinationProfileRegistry
DestinationAccessPolicy 기본값 (security) denyAll() DeclaredDestinationAccess
HandleResult → 정산 (core-api) 어댑터가 각자 결정 DefaultDeliveryProcessor

여섯 중 다섯은 배선됐고 마지막 하나(DefaultDeliveryProcessor)는 배선되지 않았다(§12.1).


2. 의존성과 런타임 배선

들어오는 것: 여섯 project 의존, 전부 api. DefaultMessagePublisher 한 클래스가 그중 다섯을 생성자로 받으므로 api가 맞다.

나가는 것: messaging-spring-boot-starter만.

배선 지점 다섯(전부 MessagingCoreAutoConfiguration):

라인 무엇
363 RegisteredMessageCodecs.of(JacksonMessageCodec.of(...))
377 DestinationProfileRegistry.of(destinations.all())
446 new DefaultMessagePublisher(destinations, access, codecs, admission, runtimes, transport)
476 new TransportMessagingRuntime(selected.brokerName(), 1L, selected)InitializingBean
DeclaredDestinationAccess.of(...)로 접근 정책 bean

446의 인자가 여섯 개라는 것이 §12.1의 관측 지점이다.


3. 패키지/컴포넌트 지도

발행 (조립됨)
  DefaultMessagePublisher
    ├── DestinationProfileRegistry   논리 이름 → DestinationProfile
    ├── DestinationAccessPolicy      ← DeclaredDestinationAccess.of(profiles)
    ├── MessageCodecRegistry         ← RegisteredMessageCodecs
    ├── MessagingAdmissionController (policy)
    ├── MessagingRuntimeRegistry     (transport-spi) → TransportMessagingRuntime
    ├── MessagingTransport           (transport-spi) → Kafka/Rabbit/…
    └── MessagingObservation         ← NO_OBSERVATION (§12.1)

소비 (조립 안 됨)
  DefaultDeliveryProcessor
    ├── Function<MessageEnvelope<EncodedMessage>, HandleResult>
    ├── DeadLetterPublisher (내부 함수형 인터페이스)
    └── OneShotSettlement → TransportSettlement

4. 계약·불변식·상태 모델

4.1 DefaultMessagePublisher — 순서가 계약이다
// :40-49
 * <p>The order below is fixed, not composed from a map of interceptors. Each stage's position is a
 * decision:
 *
 * <ul>
 *   <li>destination and access first, so an unauthorized publish never encodes a payload;
 *   <li>encoding before admission, because the admission bound is on bytes and the byte count is
 *       not known until the payload is encoded;
 *   <li>the runtime lease last before the send, so a rotation cannot swap the transport underneath
 *       a message that has already been counted against the in-flight limit.
 * </ul>

실제 순서 여덟 단계:

# 단계 실패 시
1 destinations.require(name) DESTINATION_NOT_REGISTEREDREJECTED
2 requireSupportedOptions(profile, options) PUBLISH_DEDUPLICATION_UNSUPPORTEDREJECTED
3 access.mayPublish(name) PUBLISH_FORBIDDENREJECTED (인코딩 전)
4 encode(message) PUBLISH_PREPARATION_FAILEDREJECTED
5 남은 예산 확인 PUBLISH_DEADLINE_EXCEEDEDREJECTED
6 admission.admit(name, bytes) 예외 전파(MessageTooLargeException/MessageBackpressureException)
7 runtimes.acquire(broker) PUBLISH_RUNTIME_UNAVAILABLEREJECTED
8 transport.publish(...) + 마감 타임아웃 → AMBIGUOUS / 그 외 예외 → AMBIGUOUS

17은 전부 REJECTED, 8만 AMBIGUOUS다. 그 경계가 정확히 "바이트가 프로세스를 떠났는가"다.

} catch (RuntimeException beforeTheWire) {
  // Nothing left this process, so the outcome is definite. Reporting it as ambiguous would send
  // the caller into reconciliation for a message no broker ever saw.
  return rejected("PUBLISH_PREPARATION_FAILED", sanitized(beforeTheWire), startedAt);
}

messaging-core-api의 3상태(§4.1)가 여기서 실제 분기가 된다. 그리고 rejected(...)가 만드는 PublishResultPublishEvidence.notTransmitted()를 쓰므로 PublishResult 생성자의 14가지 금지 조합 검증을 자연히 통과한다.

3번이 4번보다 먼저인 이유가 인라인 주석에 있다.

// Before encoding: an unauthorized publish must not serialise the payload, because the
// encoded bytes are what a claim-check or a log would then be holding.
4.2 예산은 호출 시점부터 센다
// :131-138
 * <p>Measured from the call, not from the send. {@code PublishOptions.timeout()} is documented as
 * the publish operation's deadline, so a slow destination lookup or a large encode spends the
 * same budget the broker wait does; timing only the transport call would let the total exceed the
 * deadline by however long preparation took.

remainingBudgettimeout - elapsedSince(startedAt)이고, 0 이하면 전송 전에 REJECTED로 끝낸다 — "Sending anyway would start a message the caller has already stopped waiting for."

4.3 마감을 복사본에 건다
// :143-154
 * <p>The bound is applied to a copy so that expiry never completes the transport's own stage: the
 * adapter still owns its in-flight publish and its own bookkeeping. The permit and the runtime
 * lease are released when the copy completes, which is deliberate  holding them until a stalled
 * broker answers is how a rotation waits forever on a generation nobody is using.
private static CompletableFuture<TransportPublishResult> withDeadline(
    CompletionStage<TransportPublishResult> inFlight, Duration remaining) {
  return inFlight.toCompletableFuture().copy()
      .orTimeout(remaining.toMillis(), TimeUnit.MILLISECONDS);
}

.copy()가 핵심이다. orTimeout을 원본에 걸면 만료가 어댑터의 stage를 완료시켜 어댑터의 자기 정리가 깨진다. 복사본에 걸면 만료는 이쪽 경로만 끝내고 어댑터는 자기 in-flight를 계속 소유한다.

그 대가도 명시돼 있다 — permit과 lease는 복사본이 완료될 때 반납되므로, 브로커가 나중에 응답해도 이미 반납된 상태다. 그것이 의도다("holding them until a stalled broker answers is how a rotation waits forever").

4.4 획득한 것은 모든 경로에서 정확히 한 번 반납된다
// :51-53
 * <p>Everything acquired is released exactly once, on every path  success, failure, exception and
 * cancellation. A permit or lease that leaks on the failure path is a limiter that shrinks by one
 * per failure until it stops accepting anything.

두 경로가 있다.

.handle((result, failure) -> {
  // One release per acquisition, whatever happened.
  held.close();
  admission.complete(destination.name().value());
  ...
});
} catch (RuntimeException beforeTheSend) {
  if (lease != null) { lease.close(); }
  admission.complete(destination.name().value());
  return rejected("PUBLISH_RUNTIME_UNAVAILABLE", ...);
}

handlewhenComplete와 달리 실패를 삼키고 값을 반환하므로 두 경우가 한 블록에서 처리된다. lease.close()MessagingRuntimeLease 계약상 멱등이고(transport-spi §4.1), admission.complete도 미보유 목적지에 대해 무해하다(messaging-policy §4.3).

한 가지 비대칭. 6번(admit)이 예외를 던지면 그 예외가 그대로 호출자에게 전파된다 — try 블록 밖이다. 다른 모든 실패는 PublishResult로 정규화되는데 admission 실패만 예외다. MessageTooLargeException·MessageBackpressureExceptionMessagingException이므로 호출자가 FailureDescriptor를 얻을 수 있지만, 반환 타입이 CompletionStage<PublishResult>인 메서드가 동기적으로 throw한다. §17.

4.5 requireSupportedOptions — 조용한 no-op을 막는다
// :65-72
 * <p>The transports accept {@code request.options()} and read nothing from it, so an option this
 * destination cannot honour has to be refused here or it is honoured nowhere. A caller asking for
 * broker-side deduplication got a publish with no deduplication and no error, and then skipped
 * the idempotency it would otherwise have written  which is exactly the case {@code
 * PublishDeduplication}'s own javadoc says must be a startup failure rather than a silent no-op.

messaging-core-apiPublishDeduplication javadoc("Requesting this on a broker without the deduplicatedPublish capability is a startup failure, not a silent no-op")이 여기서 실제 검사가 된다. 다만 startup이 아니라 publish 시점이다 — javadoc이 요구한 시점과 실제 시점이 다르다. §17.

그리고 "The transports accept request.options() and read nothing from it"은 이 leaf가 관측한 어댑터 쪽 사실이다. 어댑터 leaf SSOT들이 그것을 확인해야 한다.

4.6 encode — 폴백이 기본 codec이다
private <T> MessageEnvelope<EncodedMessage> encode(MessageEnvelope<T> message) {
  MessageCodec codec = codecs.find(message.contentType()).orElseGet(codecs::defaultCodec);
  ...
}

봉투의 content type에 맞는 codec이 없으면 기본 codec으로 인코딩한다. content type을 무시하는 폴백이다 — 봉투가 application/avro를 선언해도 registry에 Avro codec이 없으면 JSON으로 인코딩되고, EncodedMessage의 content type은 codec이 정하므로(ContentType.JSON) 봉투 선언과 실제 인코딩이 갈라진다. 그리고 출하 registry에는 JSON 하나뿐이다(§A19-MESSAGING-SCHEMA-JSON §2). §17.

RegisteredMessageCodecs.defaultCodec()이 raw bytes일 수 없다는 것은 그 클래스가 생성자에서 강제한다(§4.8).

4.7 DestinationProfileRegistry — 폴백 없는 조회
// :13-18
 * <p>Nothing resolved a logical destination to a profile before this: the brokers took an
 * already-resolved {@code DestinationProfile} and the publisher that would have produced one did
 * not exist. A registry rather than a lookup with a fallback, because a destination nobody declared
 * has no physical name, no ordering guarantee and no payload bound  publishing to it would mean
 * inventing all three at the call site.

require가 미등록 목적지에 MessagingConfigurationException("DESTINATION_NOT_REGISTERED")을 던지고 메시지가 세 가지 부재를 나열한다. empty() factory도 있다 — "every publish is refused until a destination is declared".

4.8 RegisteredMessageCodecs — 기본 codec은 명시 선택
// :18-27
 * <p>The default codec is a deliberate choice rather than "the first one registered". Selecting one
 * by iteration order means the encoding a message is written with depends on how the map was
 * populated, which is a wire-format decision made by accident. The registry takes it explicitly and
 * refuses to be constructed without it.
 *
 * <p>The raw-bytes codec is never eligible as the default  that is the contract's own rule, and
 * the reason is that raw bytes silently disable schema validation for every destination that forgot
 * to declare an encoding.

두 가지를 생성자에서 거절한다.

if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) { throw ... }
...
MessageCodec existing = into.putIfAbsent(codec.contentType(), codec);
if (existing != null && existing != codec) {
  // Two codecs for one content type is not a preference to resolve at runtime: whichever wins
  // decides how bytes on the wire are read by a consumer that was compiled against the other.
  throw new IllegalArgumentException("two codecs claim content type " + ...);
}

클래스가 아니라 content type으로 raw-bytes를 거절하는 것이 messaging-schema-api의 규칙보다 넓다 — 그 leaf §12.2가 소유한다.

4.9 TransportMessagingRuntime — 얇은 포장

MessagingRuntime 구현으로 brokerName·generation·transport 셋을 들고 close()가 CAS로 멱등이다.

// close():61-62
// Idempotent: the registry closes a drained generation, and a context shutdown may close it
// again. Closing a transport twice is not an error worth propagating into shutdown.

DefaultMessagingRuntimeRegistry(transport-spi)도 자체 closed CAS를 갖는다 — 두 층이 각각 멱등이다. 중복 방어이지만 transport-spiGeneration.forceClose()가 이미 한 번만 부르므로 이쪽 CAS는 컨텍스트 종료 경로를 위한 것이다.

generation이 항상 1L이다. starter의 유일한 설치 지점(:476)이 리터럴 1L을 넘긴다. MessagingRuntime.generation() javadoc은 "increasing with each replacement"라고 하고, TransportMessagingRuntime javadoc은 "the credential generation a rotation increments"라고 한다. 회전 코드가 없으므로 항상 1이다. §17.

4.10 DeclaredDestinationAccess — 기본값의 세 번째 선택지
// :13-32
 * <p>{@link DestinationAccessPolicy} is three sets of destination names and has a {@code denyAll()}
 * factory. Neither is a usable default on its own:
 *
 * <ul>
 *   <li><b>Deny everything</b> and the platform assembles, starts, and refuses every publish 
 *   <li><b>Allow everything</b> and the check is decoration. 
 * </ul>
 *
 * <p>So the default is neither: <b>a deployment may publish to the destinations it declared.</b>
 *  a message to a destination nobody declared is not an access-control edge case, it is a typo or
 * a module reaching past its own contract.
 *
 * <p>Consume and administer stay empty. A publisher's default has no business granting either, and
 * a deployment that needs them replaces this bean  which is the point of it being a bean.

publish만 허용하고 consume·administer는 빈 집합이다. 이것이 §12.1의 소비 경로 미조립과 정합적이다 — 기본 접근 정책이 소비를 허용하지 않는다.

4.11 DefaultDeliveryProcessor — 두 규칙 (미조립)
// :27-36
 *   <li><strong>One terminal call.</strong> A delivery is acknowledged, requeued or discarded once.
 *       A second call is a programming error  acknowledging after a requeue tells the broker the
 *       message is done while a copy is already in flight.
 *   <li><strong>Dead-letter before acknowledgement.</strong> The source is acknowledged only after
 *       the dead-letter publish is confirmed. 

OneShotSettlementAtomicBoolean CAS로 한 번을 강제하고, 두 번째 호출은 CompletableFuture.failedFuture(IllegalStateException)을 반환한다 — 예외를 던지지 않고 stage로 보고한다.

핸들러 예외 처리에 이전 결함이 기록돼 있다.

} catch (RuntimeException handlerFailed) {
  // A handler that threw is a retry, not a discard. Treating an exception as "this message is
  // undeliverable" is how a transient bug in one consumer silently drops a day of traffic —
  // and it is exactly what the Rabbit consumer did by folding handler exceptions into its
  // deserialization-failure path.
  return settlement.requeue(retryDelay);
}

result == null도 requeue다. 그런데 그것을 서술하는 missingResult() 정적 메서드가 있고 아무도 부르지 않는다HANDLER_RETURNED_NOTHING 코드가 만들어지지만 어떤 경로도 그 descriptor를 사용하지 않는다. §17.

DLQ 분기의 두 주석이 trade를 명시한다.

? settlement.acknowledge()   // Confirmed: the message exists somewhere else, so removing it here is safe.
: settlement.requeue(retryDelay);  // Not confirmed — rejected or ambiguous. Requeueing risks a
                                   // duplicate; acknowledging loses the message outright, and a
                                   // duplicate is the recoverable half of that choice.

messaging-policyDeadLetterOrchestrator가 같은 불변식을 다른 형태로 구현한다(§12.3).


5. 주요 실행 경로

발행(조립됨): §4.1의 8단계.

소비(미조립): TransportDeliveryhandler.apply(envelope)HandleResult 4분기 → OneShotSettlement로 정확히 한 번 정산.

세대 설치(조립됨): InitializingBeantransport.getIfAvailable() → null이면 조용히 반환(이유가 주석에 있음) → new TransportMessagingRuntime(brokerName, 1L, transport)runtimes.install(...).


6. 실패 경로와 복구/번역

DefaultMessagePublisher가 만드는 결과:

코드 completion category 언제
PUBLISH_FORBIDDEN REJECTED CONFIGURATION 접근 정책 거부
PUBLISH_PREPARATION_FAILED REJECTED CONFIGURATION 해석·인코딩 중 예외
PUBLISH_DEADLINE_EXCEEDED REJECTED CONFIGURATION 전송 전 예산 소진
PUBLISH_RUNTIME_UNAVAILABLE REJECTED CONFIGURATION lease 획득 실패
PUBLISH_DEADLINE_EXCEEDED AMBIGUOUS AMBIGUOUS 전송 후 마감
PUBLISH_OUTCOME_UNKNOWN AMBIGUOUS AMBIGUOUS 전송 후 그 외 실패

같은 코드 PUBLISH_DEADLINE_EXCEEDED두 completion에 쓰인다. 전송 전이면 REJECTED, 후면 AMBIGUOUS다. 코드만 보는 대시보드는 두 경우를 구분할 수 없다 — completion을 함께 봐야 한다. §17.

sanitized(Throwable)가 메시지가 아니라 타입 이름만 남긴다.

// :175-180
 * <p>A driver message can carry a routing key, a payload fragment or a connection string, and a
 * {@code FailureDescriptor} is designed to be logged and exported.
return cause.getClass().getSimpleName();

messaging-core-apiFailureDescriptor javadoc("no payload, no stack trace, no credential")과 같은 관심사다.

isDeadlinesanitized 둘 다 CompletionException을 한 겹 벗긴다 — 비동기 경로에서 원인이 감싸지기 때문이다.

DefaultDeliveryProcessor는 예외를 던지지 않는다. 이중 정산만 failedFuture로 보고한다.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

지점 도구 보호
OneShotSettlement.settled AtomicBoolean CAS 정확히 한 번 정산
TransportMessagingRuntime.closed AtomicBoolean CAS 정확히 한 번 transport close
RegisteredMessageCodecs.byContentType Map.copyOf 불변
DestinationProfileRegistry.profiles Map.copyOf 불변
withDeadline.copy() CompletableFuture 어댑터 stage와 이쪽 경로 분리

DefaultMessagePublisher 자체는 불변이고 상태를 갖지 않는다 — 필드 여덟이 전부 final 협력자다. lease만 메서드 지역 변수이고 handle 람다가 held라는 effectively-final 복사본으로 캡처한다.

수명주기 참여는 TransportMessagingRuntime.close()뿐이고, 그것을 부르는 것은 registry(회전 시)와 컨텍스트 종료 두 경로다.


8. 설정·기능 플래그·환경 차이

설정 없음. 이 leaf의 모든 값은 생성자 인자다.

주입 가능한 두 지점이 테스트 가능성을 만든다.

인자 기본 목적
LongSupplier nanoTime System::nanoTime 경과 시간을 sleep 없이 테스트
MessagingObservation observation NO_OBSERVATION 관측 주입

두 번째의 기본값이 §12.1의 발견 지점이다.

TransportMessagingRuntimegeneration은 생성자 인자이고 유일한 호출자가 1L을 넘긴다.


9. 퍼시스턴스/외부 시스템 세부

없다. 브로커 접촉은 MessagingTransport 인터페이스 뒤에 있다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-runtime-core:test. BUILD SUCCESSFUL, 21 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
DefaultMessagePublisherTest 10 8단계 순서, 각 실패의 completion·code, 마감 전후 구분, permit/lease 반납, 관측 호출 실제 브로커. 출하 조립이 관측을 넘기는지
DefaultDeliveryProcessorTest 7 HandleResult 4분기 → 정산, 핸들러 예외 → requeue, DLQ 확인 후 ack / 미확인 시 requeue, 이중 정산 거절 production에서 호출되는지(§12.1)
RegisteredMessageCodecsTest 4 raw-bytes 기본 거절, content type 충돌 거절, 조회

DefaultMessagePublisherTest:271이 익명 MessagingObservation을 만들어 관측 호출을 확인한다. 즉 테스트는 8인자 생성자를 쓰고 출하는 6인자를 쓴다. 테스트가 검증하는 경로와 출하되는 경로가 이 인자 하나만큼 다르다.

RecordingTransport(:426)가 MessagingTransport를 구현해 전송을 대체한다. 그래서 이 레인은 "발행 오케스트레이션이 옳다"를 증명하고 "어댑터가 계약을 지킨다"는 증명하지 않는다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies 여섯 project 의존
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 벤더 의존성 0
ArchUnit 전용 규칙 없음

MessagingStarterOffContractTest(starter leaf)가 이 leaf의 조립 이력을 문자열로 언급한다 — "DeadLetterOrchestrator had nothing to depend on. DefaultMessagePublisher …". 그 테스트가 무엇을 실제로 강제하는지는 starter leaf SSOT가 소유한다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/283-runtime-core-observation-noop.txt.

12.1 Public surface reachability
타입 leaf 밖 파일 출하 조립
DefaultMessagePublisher 2 oMessagingCoreAutoConfiguration:446
TransportMessagingRuntime 1 o:476
RegisteredMessageCodecs 1 o:363
DestinationProfileRegistry 1 o:377
DeclaredDestinationAccess 1 o
DefaultDeliveryProcessor 0 xsrc/main 생성 0, src/test 1

(a) 소비 경로의 유일한 오케스트레이터가 조립되지 않는다

DefaultDeliveryProcessor는 leaf 밖 참조가 0이고 src/main에서 생성되지 않는다. 이것이 §A19-MESSAGING-POLICY §12.1이 관측한 "소비 경로 전체 미조립"의 중심이다 — 어댑터의 consumer registrar들도, 재시도 실행자도, DLQ 발행자도 전부 조립되지 않는다.

이 클래스의 javadoc은 자기가 고친 문제를 서술한다 — "Each broker adapter decided for itself what a retry or a dead-letter meant, so 'the platform decides when and in what order the settlement happens' … described a decision nobody made in one place." 그 결정을 한 곳에 모았고, 그 한 곳이 배선되지 않았다.

(b) 관측이 구현·호출부·인자를 모두 갖추고도 no-op이다

네 조각이 있다.

조각 상태
MessagingObservation 인터페이스 (observability) 존재
MessagingMetrics implements MessagingObservation 존재
DefaultMessagePublisher.observe(...) 호출부 존재, 모든 발행 결과를 기록
8인자 생성자 (관측 주입) 존재
출하 조립 6인자 생성자 → NO_OBSERVATION
MessagingMetrics bean 없음
// MessagingCoreAutoConfiguration.java:446-447
return new dev.caskeleton.messaging.runtime.DefaultMessagePublisher(
    destinations, access, codecs, admission, runtimes, transport);

그리고 MessagingMetrics는 저장소 전체에서 자기 테스트에서만 생성된다(MessagingMetricCardinalityTest, MessagingSecretLeakTest).

starter는 MessagingMetrics두 협력자를 bean으로 만든다MessagingRedactor(:253)와 CardinalityGuard(:264). MessagingMetrics의 생성자는 (registry, CardinalityGuard, MessagingRedactor)를 받는다(테스트가 그렇게 호출한다). 즉 재료 둘은 배선됐고 그것을 조립하는 bean이 없다.

이 클래스의 javadoc이 그 상황을 예언한다.

// DefaultMessagePublisher.java:74-78
 * <p>{@code MessagingObservation} existed as a bean and no publish path called it, so the
 * platform's own metrics described nothing. It is a constructor argument rather than an optional
 * decorator because an unobserved publish path is how "the dashboards were empty during the
 * incident" happens.

이전 상태: bean은 있고 호출하는 경로가 없었다. 현재 상태: 호출하는 경로는 있고 bean이 없다.

두 상태의 관측 결과는 같다 — 메트릭이 비어 있다. 고침이 간극을 닫은 것이 아니라 반대편으로 옮겼다. 그리고 "constructor argument rather than an optional decorator"라는 선택이 그것을 막지 못했다 — 인자를 기본값으로 채우는 짧은 생성자가 함께 존재하기 때문이다.

(c) 배선된 것은 확실히 배선됐다

발행 경로 다섯이 전부 src/main에서 생성된다(§2 표). 대조군으로서 이 사실이 (a)와 (b)의 판정을 뒷받침한다 — 검색 방법이 조립을 놓치는 것이 아니라 실제로 조립되지 않은 것이다.

한계. 정적 검색이다. ObjectProvider 지연 조회는 MessageContractsMessagingTransport 두 곳에만 쓰이고 둘 다 확인했다. 파생 프로젝트가 MessagingObservation bean을 제공하면 @ConditionalOnMissingBean(MessagePublisher.class) 때문에 publisher bean 자체를 대체해야 한다 — 관측만 끼워 넣을 수는 없다.

12.2 Conditional sibling comparison

이 leaf에 bean은 없다. starter 쪽 sibling 비교가 유의미하다.

MessagingCoreAutoConfiguration이 이 leaf의 타입을 만드는 지점 다섯의 조건:

대상 조건
RegisteredMessageCodecs @ConditionalOnMissingBean(MessageCodecRegistry.class)
DestinationProfileRegistry @ConditionalOnMissingBean
DefaultMessagePublisher @ConditionalOnMissingBean(MessagePublisher.class)
TransportMessagingRuntime 조건 없음 — InitializingBean 안, transport.getIfAvailable() null 검사
DeclaredDestinationAccess @ConditionalOnMissingBean

네 번째만 조건 대신 런타임 null 검사를 쓴다. 그 이유가 주석에 있다.

// Not a silent skip of a check: MessagingProviderSelection is what guarantees a transport
// when a broker is selected, and it refuses startup by name when one is not. This
// configuration is also loadable on its own — an adopter composing the policy primitives
// without a transport — and demanding one here would refuse that.

즉 "transport 없이도 로드 가능해야 한다"가 명시적 요구이고, 그 요구가 @ConditionalOnBean 대신 런타임 분기를 쓰게 했다. 부재 시 조용히 반환하지만 그것이 조용한 스킵이 아님을 주석이 다른 게이트(MessagingProviderSelection)로 설명한다. 그 게이트의 실제 동작은 starter leaf SSOT가 확인해야 한다.

12.3 Duplicate mechanism sweep

(a) DLQ 순서 불변식이 두 곳에 구현돼 있다

messaging-policy DeadLetterOrchestrator 이 leaf DefaultDeliveryProcessor
불변식 확인 후에만 원본 정산 확인 후에만 ack
미확인 시 정산하지 않음(sourceSettled=false) requeue
헤더 예약 헤더 6개 부착 없음
발행 주체 MessagePublisher DeadLetterPublisher 함수형 인터페이스

미확인 시 동작이 다르다. policy 쪽은 "정산하지 않는다"(브로커가 알아서 재전달), 이쪽은 "명시적으로 requeue한다". 둘 다 메시지를 잃지 않지만 requeue(delay)는 지연을 지정하고 무정산은 브로커의 기본 재전달 타이밍을 따른다.

둘 다 조립되지 않았으므로 오늘 충돌하지 않는다. §A19-MESSAGING-POLICY §12.3(b)가 같은 사건을 반대편에서 기록한다.

(b) 재시도 지연이 두 출처

DefaultDeliveryProcessorretryDelay생성자 인자 하나다. 시도 횟수를 세지 않고 백오프도 없다. messaging-policyBackoffCalculator(지수 + full jitter + 상한)와 대비된다. 같은 leaf 문서 §12.3(a)가 소유한다.

(c) 멱등 종료가 두 층

TransportMessagingRuntime.close()DefaultMessagingRuntimeRegistry.Generation.forceClose()(transport-spi) 둘 다 CAS로 한 번을 보장한다. 중복이지만 의도된 중복이다 — 이쪽 주석이 "the registry closes a drained generation, and a context shutdown may close it again"이라고 두 경로를 명시한다. 결함 아님.

(d) content type 폴백

encodecodecs.find(contentType).orElseGet(codecs::defaultCodec)으로 폴백한다. RegisteredMessageCodecs.find는 미등록이면 Optional.empty()를 주고, defaultCodec()은 JSON이다. 즉 선언된 content type과 실제 인코딩이 갈라질 수 있는 유일한 지점이고, 그 갈라짐이 조용하다. §17.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
build.gradle 주석: MessagePublisher에 구현이 없었다 현재 이 leaf가 구현하고 :446에서 조립 해소됨
TransportMessagingRuntime javadoc: registry가 비어 있어 모든 발행이 실패했다 현재 :476이 설치 해소됨
DefaultMessagePublisher javadoc: 관측 bean이 있고 호출 경로가 없었다 현재 호출 경로가 있고 bean이 없다 반전됨(§12.1b)
DefaultDeliveryProcessor javadoc: 어댑터가 각자 결정했다 한 곳에 모았으나 조립되지 않음 부분 해소
MessagingRuntime.generation() javadoc: "increasing with each replacement" 유일한 설치가 리터럴 1L 미실현
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift)

세 번째와 다섯 번째가 이 leaf의 §17 항목이 된다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

이 leaf는 통째로 하나의 수정이다. MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다(DeclaredDestinationAccess, TransportMessagingRuntime, MessagingCoreAutoConfiguration:461).

위치 이전 상태 그것이 만든 실패
build.gradle 주석 MessagePublisher 구현 없음 자동설정이 없는 bean 위에 DLQ·facade bean을 쌓음. admission·security·lease·observation이 bean으로 존재하되 어떤 발행도 부르지 않음
TransportMessagingRuntime javadoc MessagingRuntime 구현 없음 registry가 빈 채로 만들어져 모든 발행이 PUBLISH_RUNTIME_UNAVAILABLE — 목적지 해석·접근 확인·인코딩을 전부 마친 뒤에
DestinationProfileRegistry javadoc 논리 이름→프로파일 해석 없음 어댑터는 해석된 프로파일을 받는데 그것을 만들 publisher가 없었음
DefaultDeliveryProcessor javadoc HandleResult→정산 연결 없음 각 어댑터가 retry/dead-letter의 뜻을 각자 결정
DefaultDeliveryProcessor 핸들러 예외 주석 Rabbit consumer가 핸들러 예외를 역직렬화 실패 경로로 접음 한 consumer의 일시적 버그가 하루치 트래픽을 조용히 버림
requireSupportedOptions javadoc transport가 options를 읽지 않음 중복 억제를 요청한 호출자가 억제도 오류도 못 받고, 그래서 쓸 idempotency를 건너뜀
withDeadline javadoc transport가 마감을 무시 확인이 오지 않는 Rabbit publish에 마감이 없어 호출자 스레드가 완료 불가능한 stage에 묶임

build.gradle 주석의 마지막 문장이 이 leaf 전체의 교훈이다 — "A starter that filled the gap with an application-supplied fake would pass a context test while running none of them."


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-283 command evidence/raw/283-runtime-core-observation-noop.txt 여섯 타입 참조 수, 발행 경로 조립 지점, DefaultDeliveryProcessor src/main=0, 관측 4조각과 끊긴 한 지점, MessagingMetrics가 테스트에서만 생성됨, starter가 만드는 관측 bean 둘 정적 검색. 파생 프로젝트의 대체 조립 미포함
EVD-284 command ./gradlew :messaging:messaging-runtime-core:test --rerun-tasks BUILD SUCCESSFUL, 21 / 0 / 0 브로커 대체(RecordingTransport)

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 이 leaf가 존재하는 이유와 이전 결함 — build.gradle 주석
  • 발행 8단계의 순서가 고정된 이유와 각 위치의 근거 — DefaultMessagePublisher javadoc
  • 접근 확인이 인코딩보다 먼저인 이유 — 인라인 주석
  • 전송 전 실패가 REJECTED인 이유 — 인라인 주석
  • 예산을 호출 시점부터 세는 이유 — remainingBudget javadoc
  • 마감을 복사본에 거는 이유와 그 대가 — withDeadline javadoc
  • 모든 경로에서 정확히 한 번 반납하는 이유 — 클래스 javadoc + 인라인 주석
  • 지원하지 않는 옵션을 거절하는 이유 — requireSupportedOptions javadoc
  • 기본 codec을 명시 인자로 받는 이유, raw-bytes 금지 이유 — RegisteredMessageCodecs javadoc
  • 폴백 없는 목적지 조회 이유 — DestinationProfileRegistry javadoc
  • 기본 접근 정책이 deny도 allow도 아닌 이유 — DeclaredDestinationAccess javadoc
  • 핸들러 예외가 retry인 이유 — 인라인 주석
  • DLQ 미확인 시 requeue를 고른 이유 — 인라인 주석
  • transport 부재를 조용히 넘기는 것이 조용한 스킵이 아닌 이유 — InitializingBean 안 주석
  • 관측을 생성자 인자로 둔 이유 — observation 필드 javadoc

추론

  • 출하 조립이 6인자 생성자를 쓰는 것이 의도인지 → 추론이 아니라 미상. 어디에도 근거가 없고, 8인자 생성자와 MessagingMetrics가 둘 다 존재한다는 점이 미완을 시사한다.
  • generation이 항상 1인 것은 회전 코드가 없기 때문이다 → 추론. 회전 코드 부재는 관측이다.
  • DefaultDeliveryProcessor 미조립이 미완인지 확장점인지 → 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 6개 클래스 787줄 전문의 계약과 순서 결정
  • 21개 테스트가 통과하고 무엇을 단언하는지
  • 다섯 클래스가 출하 컨텍스트에서 조립되고 정확히 어느 라인인지
  • DefaultDeliveryProcessorsrc/main에서 생성되지 않는다는 것
  • 관측의 네 조각 중 마지막 하나(bean)가 없고, 출하가 no-op 생성자를 쓴다는 것
  • MessagingMetrics가 자기 테스트에서만 생성되고, 그 협력자 둘은 bean으로 존재한다는 것
  • generation이 유일한 설치 지점에서 리터럴 1L이라는 것

확인하지 못한 것

  • 6인자 생성자 선택이 의도인지. 커밋이 대량 커밋 4개뿐이고 이 선택을 설명하는 기록이 없다.
  • MessagingProviderSelection이 실제로 transport 부재를 이름으로 거절하는지 — starter leaf가 소유한다.
  • 어댑터들이 request.options()를 정말 읽지 않는지 — 이 leaf의 javadoc이 그렇게 주장하고, 각 어댑터 leaf가 확인해야 한다.
  • 실제 브로커에서 withDeadline.copy() 전략이 어댑터 정리와 어떻게 상호작용하는지. 컨테이너 레인이 있으나 실행하지 않았다.
  • 파생 프로젝트가 publisher bean 전체를 대체해 관측을 넣는지.

17. 손볼 것

P2 — 관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다
  • 사실. DefaultMessagePublisher가 모든 발행 결과를 observation.recordPublish(...)로 기록하고, 관측을 "constructor argument rather than an optional decorator"로 받는다. MessagingMetricsMessagingObservation을 구현한다. 그런데 출하 조립(MessagingCoreAutoConfiguration:446)은 6인자 생성자를 써서 NO_OBSERVATION을 넣고, MessagingMetrics는 저장소 전체에서 자기 테스트에서만 생성된다. starter는 MessagingMetrics의 협력자 둘(MessagingRedactor:253, CardinalityGuard:264)을 bean으로 만든다.
  • 근거. evidence/raw/283 §D.
  • 왜 문제인가. 이 필드의 javadoc이 정확히 이 상황을 막으려고 쓰였다 — "an unobserved publish path is how 'the dashboards were empty during the incident' happens". 그리고 같은 javadoc이 이전 결함을 "bean은 있고 호출 경로가 없었다"로 기록한다. 지금은 반대다 — 호출 경로가 있고 bean이 없다. 관측 결과는 같다. 고침이 간극을 닫은 게 아니라 반대편으로 옮겼다. "decorator가 아니라 생성자 인자"라는 선택도 막지 못했는데, 인자를 기본값으로 채우는 짧은 생성자가 함께 있기 때문이다.
  • 확인 방법. evidence/raw/283 §D 재실행. 또는 :446의 인자 수와 :138-146 생성자 시그니처 대조.
  • 후보. (a) MessagingMetrics bean을 만들고 publisher가 8인자 생성자를 쓰게 한다. (b) 6인자 생성자를 제거해 관측을 명시 인자로 강제한다. (c) 관측이 배선되지 않았음을 support-matrix.md에 표시한다.
  • 다음 단계. CASE 후보. 재현이 정적이고, "장치는 있고 회로가 닫히지 않았다"의 변형 중 회로가 반대편에서 끊긴 사례라 독립적으로 가치가 있다. 그리고 "생성자 기본값이 있는 필수 협력자는 필수가 아니다"가 REFERENCE 후보다.
P2 — 소비 오케스트레이터가 조립되지 않는다
  • 사실. DefaultDeliveryProcessor는 leaf 밖 참조 0, src/main 생성 0, src/test 생성 1이다.
  • 근거. evidence/raw/283 §A·§C.
  • 왜 문제인가. 이 클래스가 고친 문제("각 어댑터가 retry/dead-letter의 뜻을 각자 결정")가 배선 없이는 그대로 남는다. 그리고 DeclaredDestinationAccess가 consume 권한을 빈 집합으로 두는 것과 정합적이다 — 기본 구성은 소비를 상정하지 않는다.
  • 확인 방법. git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?DefaultDeliveryProcessor\s*\(' -- src
  • 다음 단계. §A19-MESSAGING-POLICY §17의 "출하 컨텍스트가 발행은 하고 소비는 하지 못한다"와 동일 사건이다. 소유는 cross-scope 또는 starter leaf. 여기서는 교차 참조만 남긴다.
P3 — 선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다
  • 사실. encodecodecs.find(message.contentType()).orElseGet(codecs::defaultCodec)으로 폴백한다. 출하 registry에는 JSON codec 하나만 등록된다. 봉투가 application/avro를 선언해도 JSON으로 인코딩되고, EncodedMessage의 content type은 codec이 정하므로 application/json이 된다.
  • 근거. DefaultMessagePublisher.java:97-102, RegisteredMessageCodecs.find, MessagingCoreAutoConfiguration:363(varargs 비어 있음).
  • 왜 문제인가. 실패하지 않고 다른 포맷으로 성공한다. 소비 측이 봉투의 원래 선언을 믿고 디코더를 고르면 어긋난다. DestinationProfile.schema().codec()이 목적지의 codec을 선언하는데 그 값과 대조하는 코드가 이 경로에 없다.
  • 확인 방법. 등록되지 않은 content type의 봉투를 발행해 EncodedMessage.contentType()을 확인.
  • 후보. 미등록 content type을 MessagingConfigurationException으로 거절하거나, profile.schema().codec()과 대조한다.
  • 다음 단계. CASE 후보. 조용한 성공이라는 형태가 messaging-core-api의 "조용한 성능 저하 금지" 설계와 정면으로 어긋난다.
P3 — 같은 실패 코드가 두 completion에 쓰인다
  • 사실. PUBLISH_DEADLINE_EXCEEDED가 전송 전이면 REJECTED(:16-21), 전송 후면 AMBIGUOUS(:42-47)로 붙는다.
  • 근거. 두 위치.
  • 왜 문제인가. 두 경우의 운영자 행동이 정반대다 — 전자는 버려도 안전, 후자는 같은 messageId로만 재발행. FailureDescriptor.code가 "stable, machine-readable code"이고 대시보드가 그것으로 집계하는데, 이 코드는 completion을 함께 보지 않으면 판단을 뒤집는다.
  • 확인 방법. git grep -n 'PUBLISH_DEADLINE_EXCEEDED' -- src/messaging/messaging-runtime-core
  • 후보. 전송 전을 PUBLISH_DEADLINE_BEFORE_SEND처럼 분리한다.
  • 다음 단계. REFERENCE 후보(안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다).
P3 — admission 실패만 예외로 전파된다
  • 사실. 8단계 중 admission(:24)만 try 블록 밖이고, MessageTooLargeException·MessageBackpressureException이 그대로 던져진다. 나머지 실패는 전부 CompletionStage<PublishResult>로 정규화된다.
  • 근거. DefaultMessagePublisher.java:198(admit 호출 위치)과 그 앞뒤 try 블록 범위.
  • 왜 문제인가. 반환 타입이 CompletionStage인 메서드가 동기적으로 throw한다. .publish(...).exceptionally(...)로만 처리하는 호출자는 이 두 예외를 놓친다. 두 예외 다 MessagingException이라 FailureDescriptor는 있지만 전달 방식이 다른 실패들과 다르다.
  • 확인 방법. 상한 초과 payload로 publish를 호출하고 반환 stage가 아니라 호출 자체가 던지는지 확인.
  • 후보. admission을 try 안으로 넣어 rejected(...)로 정규화하거나, javadoc에 동기 throw를 명시한다.
  • 다음 단계. REFERENCE 후보(CompletionStage를 반환하는 메서드는 동기적으로 던지지 않는다).
P3 — generation이 항상 1이다
  • 사실. 유일한 설치 지점(MessagingCoreAutoConfiguration:476)이 리터럴 1L을 넘긴다. MessagingRuntime.generation() javadoc은 "increasing with each replacement", TransportMessagingRuntime javadoc은 "the credential generation a rotation increments"라고 한다.
  • 근거. :476, 두 javadoc.
  • 왜 문제인가. 오늘 회전 코드가 없으므로 무해하다. 다만 DefaultMessagingRuntimeRegistry의 세대 드레인 로직(transport-spi §4.2)이 세대 구분을 전제하고, 진단에서 generation을 읽는 사람은 항상 1을 본다. 회전을 붙일 때 이 리터럴이 잊히면 두 세대가 같은 번호를 갖는다.
  • 확인 방법. git grep -n 'TransportMessagingRuntime(' -- src/main
  • 후보. 자격증명 회전 카운터에서 값을 가져오거나, 회전이 없음을 주석으로 남긴다.
  • 다음 단계. REFERENCE 후보(증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다).
P3 — missingResult()가 아무 데도 쓰이지 않는다
  • 사실. DefaultDeliveryProcessor.missingResult()(package-private static)가 HANDLER_RETURNED_NOTHING descriptor를 만든다. result == null 분기는 그것을 쓰지 않고 바로 settlement.requeue(retryDelay)를 부른다.
  • 근거. DefaultDeliveryProcessor.java:77-79, :146-154.
  • 왜 문제인가. 핸들러가 null을 반환한 경우와 HandleResult.Retry를 반환한 경우가 정산 수준에서 구분되지 않는다. 전자는 프로그래밍 오류이고 후자는 정상 흐름인데 같은 requeue가 된다. descriptor는 만들어졌으나 흐르지 않는다.
  • 확인 방법. git grep -n 'missingResult' -- src
  • 후보. null 분기에서 descriptor를 관측이나 로그로 흘리거나, 메서드를 제거한다.
  • 다음 단계. REFERENCE 후보(만들어 두고 흘리지 않는 진단값은 진단이 아니다).
확인된 설계(문제 아님)
  • 발행 8단계의 고정 순서와 각 위치의 명시된 근거
  • 전송 전/후 경계가 REJECTED/AMBIGUOUS를 가르는 것
  • 예산을 호출 시점부터 세는 것
  • 마감을 복사본에 걸어 어댑터의 stage를 완료시키지 않는 것과, permit/lease를 그 시점에 반납한다는 명시적 trade
  • 성공·실패·예외 모든 경로에서 lease와 permit을 정확히 한 번 반납하는 것
  • 지원하지 않는 발행 옵션을 조용히 무시하지 않고 거절하는 것
  • 기본 codec을 명시 인자로 받고 raw-bytes를 content type 기준으로 거절하는 것
  • 폴백 없는 목적지 조회
  • 기본 접근 정책이 "선언한 목적지에만 발행"인 것과 consume·administer를 비워 두는 것
  • 정확히 한 번 정산(CAS)과 핸들러 예외를 retry로 취급하는 것
  • 실패 서술에 예외 메시지가 아니라 타입 이름만 남기는 것

Source anchors

id kind path revision what it proves limitations
MRC-001 registry src/config/architecture/modules.json 21234e38 deps 6개, memberships ["app-bootstrap"] 선언
MRC-002 build messaging-runtime-core/build.gradle same 이 leaf가 존재하는 이유(MSG-INT-003 진단)
MRC-003 code .../runtime/DefaultMessagePublisher.java 전문 same §4.14.6, §6 브로커 대체 테스트만
MRC-004 code .../runtime/DefaultDeliveryProcessor.java 전문 same §4.11 두 규칙, 핸들러 예외 이력 조립되지 않음(§12.1a)
MRC-005 code .../runtime/RegisteredMessageCodecs.java same §4.8 기본 codec 규칙과 충돌 거절
MRC-006 code .../runtime/DestinationProfileRegistry.java same §4.7 폴백 없는 조회
MRC-007 code .../runtime/TransportMessagingRuntime.java same §4.9 멱등 종료, generation 인자 항상 1(§17)
MRC-008 code .../runtime/DeclaredDestinationAccess.java same §4.10 기본 접근 정책의 세 번째 선택지
MRC-009 test DefaultMessagePublisherTest (10) same 8단계와 실패 정규화, 관측 호출 8인자 생성자 사용
MRC-010 test DefaultDeliveryProcessorTest (7) same 4분기 정산, 이중 정산 거절 배선 미증명
MRC-011 test RegisteredMessageCodecsTest (4) same 기본 codec 규칙
MRC-012 assembly messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:363,377,446,461-478 same 다섯 조립 지점과 6인자 생성자 선택 해당 leaf SSOT가 소유
MRC-013 cross-leaf code messaging-observability/.../MessagingMetrics.java:29 same MessagingObservation의 유일한 구현 테스트에서만 생성
MRC-014 cross-leaf code messaging-policy/.../DeadLetterOrchestrator.java same 경쟁하는 DLQ 구현 해당 leaf SSOT가 소유
EVD-283 command evidence/raw/283-runtime-core-observation-noop.txt same §12.1 전부 정적 검색
EVD-284 command ./gradlew :messaging:messaging-runtime-core:test --rerun-tasks same 21 / 0 / 0 RecordingTransport 대체

A19-MESSAGING-SCHEMA-API. messaging-schema-api

분석 중에는 messaging/MESSAGING-SCHEMA-API.md 파일이었다. 547줄.

messaging-schema-api 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-schema-api SSOT owner: messaging-schema-api integration/family document: §A19 (secondary, INTEGRATION_ONLY)

성격. 읽기 기록이다. 이 leaf가 선언한 codec/schema 계약과, 그 중 무엇이 실제로 호출되는지를 source anchor와 함께 적는다.


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-schema-api
  • canonical state analysisFile: §A19-MESSAGING-SCHEMA-API
  • source path: src/messaging/messaging-schema-api
  • leaf-owned subdocuments: 없음
  • registry allowed_dependencies: ["messaging-core-api"]
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 10
production LOC 630
패키지 1 (dev.caskeleton.messaging.schema)
test 파일 3
test 메서드(실행 확인) 19
외부(비프로젝트) 의존성 0

10개 타입의 성격:

타입 종류 역할
MessageCodec interface 한 wire 포맷의 인코딩/디코딩
MessageCodecRegistry interface content type → codec, 그리고 기본 codec
SchemaRegistry interface subject/version → schema, 그리고 compatibility mode
MessageContractKey record (MessageType, SchemaVersion) — registry 키
SchemaReference record subject + version + 선택적 URI
EncodedMessage record 바이트 + content type + schema reference
SchemaCompatibility enum(7) 진화 모드
SchemaCompatibilityValidator class 포맷 독립 진화 규칙
BoundedByteSink class 한도 초과 바이트를 쓰기 시점에 거절하는 OutputStream
RawBytesMessageCodec class 스키마 없는 M2 escape hatch
Coverage ledger
scope/file group count disposition reason
src/main/java/** (10) 10 FULL_READ 전 파일 본문 확인
src/test/java/** (3) 3 FULL_READ 전 파일 본문 확인
build.gradle 1 FULL_READ 5줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

이 leaf는 "바이트를 어떻게 만들고 읽는가"의 계약을 소유한다. 실제 포맷 구현은 갖지 않는다 — 단 하나의 예외가 RawBytesMessageCodec이고, 그것은 포맷이 아니라 포맷의 부재를 구현한다.

경계 규칙 하나가 모든 곳에 반복된다: codec은 닫힌 registry에 대해서만 동작한다.

// MessageCodec.java:10-12
 * <p>Implementations operate against a closed message-type registry. Accepting an unregistered type
 * would let a producer introduce a wire contract nothing has reviewed, which is the same class of
 * problem that makes Java serialization unsupported here.

build.gradleapi project(':messaging:messaging-core-api') 하나뿐이고 vendor 의존성이 없다. 포맷별 vendor(jackson, avro, protobuf)는 각자 leaf가 갖는다.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api 노출).

나가는 것: messaging-schema-json, messaging-schema-avro, messaging-schema-protobuf, messaging-cloudevents, messaging-policy, messaging-transport-spi, messaging-runtime-core, messaging-kafka, messaging-rabbit, messaging-pulsar-experimental, messaging-nats-experimental, messaging-spring-boot-starter, messaging-testkit.

런타임 편입은 messaging-core-api와 같은 경로다 — app-bootstrapmessaging-spring-boot-starter를 선언하고 그 closure가 이 leaf를 끌어온다.

이 leaf는 bean을 만들지 않는다. Spring 주석 0개.


3. 패키지/컴포넌트 지도

패키지 하나에 10개 타입이 평평하게 있다. 관심사로 나누면 셋이다.

codec 축          MessageCodec ── MessageCodecRegistry
                       │
                       └── RawBytesMessageCodec (유일한 구현)

식별 축           MessageContractKey (type, version)
                  SchemaReference    (subject, version, uri?)
                  EncodedMessage     (bytes, contentType, schemaReference?)

진화 축           SchemaRegistry ── SchemaCompatibility(7)
                       │
                       └── SchemaCompatibilityValidator

경계 축           BoundedByteSink

4. 계약·불변식·상태 모델

4.1 MessageContractKey: 버전을 키에 넣는 이유

이 leaf에서 가장 밀도 높은 javadoc이다.

// MessageContractKey.java:10-17
 * <p>Keying on the message type alone is what let an unregistered version decode. The version
 * travels in the envelope and in {@link SchemaReference}, so a consumer receiving {@code
 * order.created v999} would look up {@code order.created}, find the v1 class or parser, decode
 * against it, and then keep the v999 label on the result. Nothing failed, and every downstream
 * compatibility gate and audit record then described a version that was never registered.

핵심은 "Nothing failed"다. 타입만으로 키를 잡으면 실패가 발생하지 않고 잘못된 성공이 발생한다. 그리고 그 결과에는 등록된 적 없는 버전 라벨이 붙어 하위 감사 기록까지 오염된다.

이 결정은 세 codec에 전부 반영돼 있다 — JacksonMessageCodec.requireRegistered, AvroMessageCodec.schemaFor, ProtobufMessageCodec.requireRegistered가 모두 "타입은 아는데 버전을 모른다"와 "타입 자체를 모른다"를 다른 에러 코드로 구분한다(SCHEMA_VERSION_NOT_REGISTERED vs UNKNOWN_MESSAGE_TYPE). 그 구분이 있어야 운영자가 "등록을 빠뜨렸다"와 "오타다"를 나눌 수 있다.

4.2 BoundedByteSink: 보고 임계값 → 할당 경계
// BoundedByteSink.java:11-15
 * <p>Every codec here used to serialize into an unbounded buffer and compare {@code bytes.length}
 * to the configured maximum afterwards. That makes the maximum a reporting threshold rather than an
 * allocation bound: a payload whose graph expands to hundreds of megabytes exhausts the heap while
 * being written, and the check that would have rejected it never runs. Under a broker consumer that
 * is a process-wide outage caused by one message.

세 가지 설계 결정이 붙어 있다.

  1. 버퍼를 한도로 미리 잡지 않는다. new ByteArrayOutputStream(Math.min(maxBytes, 8_192)) — 주석: "a 1 GiB bound must not pre-allocate 1 GiB."
  2. codec의 에러 코드를 그대로 던진다. errorCode가 생성자 인자다. 그래서 Avro는 AVRO_PAYLOAD_TOO_LARGE, JSON은 PAYLOAD_TOO_LARGE가 나온다. 테스트가 이 성질을 직접 단언한다(BoundedByteSinkTest.java:69-77, as("the sink reports the codec's own code, not a generic one")).
  3. requireFits(size)는 예산을 소비하지 않는다. Protobuf는 직렬화 크기를 미리 알므로 첫 바이트 전에 거절할 수 있다. 그리고 그 뒤의 쓰기도 여전히 경계 안이다 — 주석: "this is a cheaper refusal, not a replacement for the bound."

refuseIfBeyondLimitsize > maxBytes - written으로 비교하는 것도 의도적이다. written + size > maxBytes였다면 int 오버플로가 가능하다.

테스트가 실제 시나리오를 재현한다 — 10 MiB를 1 KiB씩 제공하고, written()이 한도(64) 이하로 유지되며 toByteArray()가 비어 있음을 확인한다(BoundedByteSinkTest.java:34-53).

4.3 EncodedMessage: 양방향 방어 복사
public EncodedMessage {
  ...
  bytes = bytes.clone();          // 생성 시
}

@Override
public byte[] bytes() {
  return bytes.clone();           // 접근 시
}

javadoc이 이유를 적는다 — "These bytes travel through retry, DLQ, and redrive paths where a shared mutable array would let one stage corrupt another's copy of the same logical message."

equals/hashCodeArrays.equals/Arrays.hashCode로 재정의된다(record 기본은 배열 참조 비교라 항상 불일치). toString은 바이트를 찍지 않고 크기만 찍는다 — payload가 로그에 새지 않는다.

size()가 복사 없이 길이를 반환하는 별도 메서드로 있는 것도 의도적이다. bytes().length는 전체 복사를 유발한다.

4.4 SchemaCompatibility: 7개 모드와 transitive의 의미
// SchemaCompatibility.java:6-8
 * <p>Transitive modes check every historical version, not just the immediate predecessor. That
 * matters for integration events, where a consumer may be several releases behind and a chain of
 * individually-compatible changes can still be collectively breaking.

NONE_EXPERIMENTAL은 "M2 raw bytes에만 허용"이라고 enum 상수 javadoc이 적는다.

4.5 SchemaRegistry: 포트이고, 순서가 계약이다
// SchemaRegistry.java:16-17
 * <p>{@link #history} returns oldest first. Transitive compatibility checks read the whole list, so
 * an ordering mistake here silently converts a transitive check into a pairwise one.

이것은 문서화된 함정이다. history가 newest-first로 구현되면 versionsToCheckreversed()한 뒤 history.get(0)을 취하므로 가장 오래된 버전 하나만 비교하게 된다 — transitive가 pairwise로 조용히 축소되는 것이 아니라 아예 엉뚱한 버전을 비교한다.

latest(subject)가 default 메서드로 versions.get(versions.size() - 1)인 것도 같은 순서 계약에 의존한다. 테스트가 이 성질을 직접 단언한다(SchemaCompatibilityValidatorTest.theLatestVersionIsTheNewestNotTheFirstListed).

port로 둔 이유도 적혀 있다 — "A hosted registry, a classpath directory of schema files, and a static in-process map are all legitimate sources … Binding to a vendor client here would make the rules untestable without that vendor running."

4.6 SchemaCompatibilityValidator: 포맷 독립 규칙

두 가지를 한다.

(a) 비교할 버전 목록

public List<SchemaVersion> versionsToCheck(String subject) {
  SchemaCompatibility mode = registry.compatibilityOf(subject);
  if (mode == SchemaCompatibility.NONE_EXPERIMENTAL) return List.of();
  List<SchemaVersion> history = registry.history(subject).reversed();
  if (history.isEmpty()) return List.of();
  return isTransitive(mode) ? history : List.of(history.get(0));
}

(b) production 목적지 게이트

public void requireProductionMode(String subject, String destination) {
  if (registry.compatibilityOf(subject) == SchemaCompatibility.NONE_EXPERIMENTAL) {
    throw new MessageSchemaIncompatibleException(
        "UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION", ...);
  }
}

javadoc이 이유를 적는다 — "A mode that checks nothing is useful while a message type is being designed and actively dangerous once a retained log exists, because the log outlives every consumer that could still read it."

그리고 분리 자체의 이유를 명시한다:

// SchemaCompatibilityValidator.java:12-14
 * <p>Split from the per-format gates on purpose. Whether v3 must be checked against v1 as well as
 * v2 is a property of the compatibility mode, not of Avro or Protobuf, and duplicating that
 * reasoning in each codec is how the two formats drift apart.

§12.1과 §12.3이 이 문장을 다시 다룬다.

4.7 RawBytesMessageCodec: 부재를 구현한다
// RawBytesMessageCodec.java:12-16
 * <p>It still enforces the byte limit, and it is deliberately excluded from default codec
 * selection: schema-free publishing has to be an explicit, auditable choice per destination, never
 * something a destination falls back to because its codec was misconfigured.

encodebyte[]가 아닌 payload를 MessageSerializationException("RAW_BYTES_PAYLOAD_REQUIRED")로 거절하고, decodebyte[].class가 아닌 대상을 RAW_BYTES_TARGET_REQUIRED로 거절한다. decodeencoded.clone()을 반환한다 — 호출자가 원본을 건드릴 수 없다.

DEFAULT_MAX_BYTES = 1_048_576(1 MiB)은 세 Stable codec이 공유하는 값이다.

주의: 이 codec은 BoundedByteSink를 쓰지 않는다. 이미 byte[]를 받으므로 스트리밍 경계가 의미 없고, bytes.length > maxBytes 비교로 충분하다. 다른 codec에서는 그 비교가 §4.2가 지적하는 "보고 임계값"이지만 여기서는 할당이 이미 끝난 입력이라 성격이 다르다.


5. 주요 실행 경로

세 개다.

  1. 경계 있는 인코딩 — codec이 BoundedByteSink.of(maxBytes, code)를 만들고 → 포맷 라이브러리가 sink에 쓰고 → 한도를 넘는 write에서 MessageTooLargeException → 아니면 sink.toByteArray()EncodedMessage 조립
  2. 계약 조회new MessageContractKey(type, version) → registry lookup → 미스면 "타입 미등록" vs "버전 미등록" 구분
  3. 진화 검사registry.compatibilityOf(subject)versionsToCheck → (포맷별 게이트가 실제 비교)

3번은 이 저장소에서 실행되지 않는다(§12.1).


6. 실패 경로와 복구/번역

이 leaf가 던지는 예외는 셋이고 전부 messaging-core-api 소유다.

예외 코드 조건
MessageTooLargeException codec별(PAYLOAD_TOO_LARGE, AVRO_PAYLOAD_TOO_LARGE, …) sink 한도 초과
MessageTooLargeException RAW_BYTES_TOO_LARGE raw codec 한도 초과
MessageSerializationException RAW_BYTES_PAYLOAD_REQUIRED / RAW_BYTES_TARGET_REQUIRED 타입 불일치
MessageSchemaIncompatibleException UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION NONE_EXPERIMENTAL이 production 목적지에

IllegalArgumentException도 던진다 — BoundedByteSink 생성자의 maxBytes < 1, requireFits의 음수, SchemaReference의 빈 subject. 이들은 호출자의 프로그래밍 오류이고 메시지 실패가 아니므로 MessagingException 계층 밖인 것이 일관적이다.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

동시성: BoundedByteSink의도적으로 thread-safe가 아니다. javadoc이 명시한다 — "Not thread-safe, and not meant to be: an instance belongs to a single encode call." 실제로 codec들이 매 encode 호출마다 새로 만든다.

EncodedMessage, MessageContractKey, SchemaReference는 불변이다. SchemaCompatibilityValidator는 registry 참조만 갖고 상태가 없다.

MessageCodecRegistry/SchemaRegistry 구현의 스레드 안전성은 이 leaf가 규정하지 않는다 — port javadoc에 그에 대한 요구가 없다. 이것은 §17의 P3 항목이다.


8. 설정·기능 플래그·환경 차이

설정 없음. 상수 하나:

상수 위치
RawBytesMessageCodec.DEFAULT_MAX_BYTES 1,048,576 RawBytesMessageCodec.java:21

BoundedByteSink의 초기 버퍼 상한 8,192는 private다.


9. 퍼시스턴스/외부 시스템 세부

없다. SchemaRegistry가 외부 registry를 가리킬 수 있는 port지만, 이 leaf에는 구현이 없다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-schema-api:test. BUILD SUCCESSFUL, 19 tests, 0 skipped, 0 failures (--rerun-tasks, revision 21234e38).

클래스 실제로 증명하는 것 증명하지 않는 것
BoundedByteSinkTest 4 한도 포함/초과 경계, 10 MiB 스트림이 한도에서 멈춤, pre-flight가 예산을 안 먹음, codec 에러 코드 전달 실제 codec들이 이 sink를 쓰는지(각 codec leaf가 소유)
RawBytesMessageCodecTest 6 round trip, content type, 비-byte[] 거절 양방향, 한도, EncodedMessage 방어 복사
SchemaCompatibilityValidatorTest 9 pairwise vs transitive 목록, NONE_EXPERIMENTAL 빈 목록, 빈 history, production 게이트 양방향, checksBackward/checksForward 조합, latest가 newest production 코드가 이 validator를 호출하는지

마지막 칸이 핵심이다. SchemaCompatibilityValidatorTest는 9개 단언으로 규칙을 정확히 고정하지만, §12.1이 보이듯 그 규칙을 실행 경로에서 부르는 코드가 없다. 테스트는 규칙이 옳다를 증명하고 규칙이 적용된다를 증명하지 않는다.

테스트가 쓰는 FixedRegistrySchemaRegistry의 유일한 구현이다(production 구현 0개, §12.1).


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
registry fail-closed 등록됨
verifyCleanArchitectureDependencies allowed_dependencies: ["messaging-core-api"]와 실제 project edge 대조
verifyRuntimeModuleMembership ["app-bootstrap"]
src/messaging/CLAUDE.md의 vendor api 규칙 이 leaf는 vendor 의존성이 없으므로 대상 없음
ArchUnit 이 leaf 전용 규칙 없음

src/messaging/CLAUDE.md:40-43이 기술하는 게이트 — "source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 build.gradleapi로 선언했는지 대조" — 는 이 leaf에서 확인할 것이 없다. 형제 leaf(schema-avro, schema-protobuf, cloudevents)는 이 규칙 때문에 vendor를 api로 선언했고 build.gradle 주석이 그 이유를 적는다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/272-schema-family-reachability.txt.

12.1 Public surface reachability

leaf 밖 참조를 파일 수로 세면:

타입 leaf 밖 파일 수 판정
EncodedMessage 50 널리 쓰임 — 사실상 이 leaf의 주력 수출품
SchemaCompatibility 15 세 codec leaf + policy가 씀
MessageContractKey 7 세 codec leaf가 씀
MessageCodec 7 세 codec + runtime-core
MessageCodecRegistry 5 runtime-core가 구현
SchemaReference 4 codec들이 만듦
BoundedByteSink 3 JSON·Avro·Protobuf codec
RawBytesMessageCodec 0 자기 테스트만
SchemaCompatibilityValidator 0 자기 테스트만
SchemaRegistry 0 아래 참조

SchemaRegistry의 "0"은 확인이 필요했다. 단순 이름 검색은 2개 파일을 맞췄지만 둘 다 다른 타입이다:

src/adapter/outbound/messaging/.../LocalJsonSchemaRegistry.java:6:  import com.networknt.schema.SchemaRegistry;
src/adapter/outbound/notification/.../JsonSchemaVariableValidator.java:5: import com.networknt.schema.SchemaRegistry;

import dev.caskeleton.messaging.schema.SchemaRegistry 검색은 exit 1이다. 즉 이 플랫폼의 SchemaRegistry port를 import하는 파일이 저장소에 하나도 없다. 이름 충돌이 우연히 검색을 오염시킨 사례이고, -w 단어 매칭만으로 reachability를 판정하면 안 되는 이유이기도 하다.

SchemaCompatibilityValidator의 "0"이 이 leaf에서 가장 무거운 사실이다. 검색 결과 전체가 자기 선언과 자기 테스트다. 다시 말해:

  • 어떤 버전들을 비교해야 하는가 → 아무도 묻지 않는다
  • NONE_EXPERIMENTAL이 production 목적지를 뒷받침할 수 있는가 → 아무도 묻지 않는다

requireProductionMode는 "retained log outlives every consumer"라는 이유로 만들어졌고, 그 게이트가 호출되는 지점이 없다.

RawBytesMessageCodec의 "0"은 성격이 다르다. 이 클래스가 없어도 그 규칙은 살아 있다 — §12.2 참조.

12.2 Conditional sibling comparison

Spring 주석 0개이므로 bean 활성화 비대칭은 없다.

대신 이 leaf에는 다른 형태의 sibling 비대칭이 있고 결과가 좋다. RawBytesMessageCodec의 javadoc이 "deliberately excluded from default codec selection"이라고 선언하는 규칙을, 실제로 강제하는 코드는 다른 leaf에 있다:

// messaging-runtime-core/RegisteredMessageCodecs.java:52-56
if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) {
  throw new IllegalArgumentException(
      "the raw bytes codec must not be the default: every destination that has not declared an "
          + "encoding would silently skip schema validation");
}

클래스가 아니라 content type으로 판정한다. 그래서 RawBytesMessageCodec을 아무도 쓰지 않아도, 그리고 누가 ContentType.OCTET_STREAM을 내놓는 다른 codec을 새로 만들어도 규칙이 유지된다. 선언된 규칙과 강제하는 코드가 다른 leaf에 있으면서 강제 쪽이 더 넓은 드문 경우다. 결함이 아니라 확인된 설계로 기록한다.

12.3 Duplicate mechanism sweep

SchemaCompatibilityValidator가 막으려던 중복이 실제로 존재한다.

AvroCompatibilityGate(다른 leaf)가 같은 판단을 private static으로 다시 구현했다.

판단 schema-api (SchemaCompatibilityValidator) schema-avro (AvroCompatibilityGate)
transitive인가 mode == BACKWARD_TRANSITIVE || FORWARD_TRANSITIVE || FULL_TRANSITIVE (:107-112) 같은 식을 그대로 (:49-53)
후방 검사하나 mode == BACKWARD || BACKWARD_TRANSITIVE || FULL || FULL_TRANSITIVE허용목록 (:79-85) mode != FORWARD && mode != FORWARD_TRANSITIVE거부목록 (:55-57)
전방 검사하나 mode == FORWARD || FORWARD_TRANSITIVE || FULL || FULL_TRANSITIVE허용목록 (:93-99) mode != BACKWARD && mode != BACKWARD_TRANSITIVE거부목록 (:59-61)

isTransitive는 글자까지 동일한 복사본이다. 방향 판정 둘은 형태가 반대다.

현재 enum 7개 값에 대해 두 구현의 결과를 대조하면 일치한다. NONE_EXPERIMENTAL만 다른데(validator는 둘 다 false, gate는 둘 다 true) AvroCompatibilityGate.check:34가 그 모드에서 먼저 return하므로 가려진다.

문제는 오늘의 불일치가 아니라 형태다. 허용목록은 새 모드가 추가되면 "검사 안 함"으로 기본값이 잡히고, 거부목록은 "양방향 검사"로 잡힌다. SchemaCompatibility에 값이 하나 추가되는 순간 두 구현은 반대 방향으로 갈라진다. javadoc이 예고한 "how the two formats drift apart"가 바로 이 형태이고, 그것을 막으려고 만든 클래스는 §12.1에서 보듯 호출되지 않는다.

isTransitiveSchemaCompatibilityValidator에서 public static이다. Avro 게이트가 그것을 부를 수 있었고 부르지 않았다.

12.4 Documentation / measured-count drift

이 leaf를 직접 이름으로 언급하는 문서 주장을 재측정했다.

문서 주장 재측정 결과
계획 문서: codec은 닫힌 registry에 대해 동작 MessageCodec javadoc + 세 구현의 requireRegistered/schemaFor 일치
RawBytesMessageCodec javadoc: 기본 codec 선택에서 제외됨 RegisteredMessageCodecs.of 생성자 검사 일치(더 넓게 강제)
SchemaRegistry javadoc: history는 oldest-first 유일한 구현이 테스트 fixture이고 그 계약을 지킴 일치하나 production 구현 없음

§12.4의 family 전체 drift(support-matrix.md:23의 runtime membership 주장)는 §A19-MESSAGING-CORE-API §12.4가 소유한다. 이 leaf도 그 18개 wired 목록에 포함된다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

코드 주석이 보존한 이전 결함:

위치 이전 상태 그것이 만든 실패
BoundedByteSink javadoc 각 codec이 무제한 버퍼에 직렬화 후 길이 비교 한도가 보고 임계값일 뿐 할당 경계가 아님 → 팽창하는 payload 하나가 consumer 프로세스를 죽임
MessageContractKey javadoc 타입만으로 registry 키 v999가 v1 클래스로 디코딩되고 v999 라벨을 유지 → 하위 게이트·감사 기록이 등록된 적 없는 버전을 서술

두 사례 다 형태가 같다 — 검사가 없었던 게 아니라 검사의 위치/키가 틀렸다. messaging-core-api §13의 "문자 vs 바이트, 정확일치 vs 세그먼트" 목록과 같은 계열이다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-272 command evidence/raw/272-schema-family-reachability.txt SchemaCompatibilityValidator 호출자 전무, allowlist/denylist 두 형태 나란히, SchemaRegistry port import 0(exit=1)과 이름 충돌, codec별 소비자 정적 git grep
EVD-274 command ./gradlew :messaging:messaging-schema-api:test --rerun-tasks BUILD SUCCESSFUL, 19 / 0 / 0 순수 단위 레인

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 버전을 registry 키에 넣는 이유 — MessageContractKey javadoc
  • 할당 경계 vs 보고 임계값 — BoundedByteSink javadoc
  • codec 에러 코드를 sink에 넘기는 이유 — BoundedByteSink javadoc + 테스트 as(...)
  • 포맷 독립 규칙을 분리한 이유 — SchemaCompatibilityValidator javadoc
  • NONE_EXPERIMENTAL을 production에서 막는 이유 — 같은 javadoc
  • SchemaRegistry를 port로 둔 이유, history 순서가 계약인 이유 — SchemaRegistry javadoc
  • raw codec을 기본에서 제외하는 이유 — RawBytesMessageCodec javadoc + RegisteredMessageCodecs javadoc
  • EncodedMessage 양방향 복사 이유 — EncodedMessage javadoc

추론

  • SchemaCompatibilityValidator가 미호출인 것은 이 저장소에 schema registry를 실제로 운영하는 배포가 없기 때문이다 → 추론. SchemaRegistry production 구현이 0인 것은 관측이고, 인과는 추론이다.
  • Avro 게이트가 자기 복사본을 쓴 이유 → 미상. 커밋 메시지에 근거가 없다.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 10개 타입 전부의 계약과 불변식
  • 19개 테스트가 통과하고 무엇을 단언하는지
  • SchemaCompatibilityValidator·RawBytesMessageCodec·SchemaRegistry의 leaf 밖 참조 0 (SchemaRegistry는 이름 충돌을 배제한 뒤)
  • Avro 게이트의 중복 구현과 두 형태의 차이
  • raw-bytes 기본 금지 규칙이 content type 기준으로 더 넓게 강제된다는 것

확인하지 못한 것

  • SchemaCompatibility enum이 실제로 확장될 계획이 있는지. §12.3의 위험은 그때 실현된다.
  • port 구현의 스레드 안전성 요구. javadoc에 없고 이 저장소에 production 구현이 없어 관측할 대상이 없다.
  • BoundedByteSink의 경계가 실제 Jackson/Avro/Protobuf 인코더에서 기대대로 동작하는지 — 각 codec leaf의 테스트가 소유하고 이 문서 범위 밖이다.

17. 손볼 것

P2 — 포맷 독립 진화 규칙이 호출되지 않고, 그것이 막으려던 중복이 실제로 생겼다
  • 사실. SchemaCompatibilityValidator의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다. 동시에 AvroCompatibilityGateisTransitive를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다.
  • 근거. evidence/raw/272 §A, §B.
  • 왜 문제인가. 오늘은 7개 모드 전부에서 두 구현의 결과가 같다(NONE_EXPERIMENTAL은 gate의 early return이 가린다). 그러나 enum에 값이 하나 추가되면 허용목록은 "검사 안 함", 거부목록은 "양방향 검사"로 반대 방향 기본값을 갖는다. 그리고 requireProductionMode — 검사 없는 스키마가 보존 로그를 뒷받침하는 것을 막는 게이트 — 는 호출되는 곳이 없다.
  • 확인 방법. git grep -n -E 'requireProductionMode|versionsToCheck|SchemaCompatibilityValidator' -- 'src/**/*.java'
  • 후보. (a) Avro 게이트가 SchemaCompatibilityValidator의 public static을 부르게 한다. (b) validator를 CI 게이트에 배선한다. (c) 둘 다 쓰지 않을 거라면 validator를 제거하고 규칙 소유권을 게이트로 옮긴다.
  • 다음 단계. CASE 후보 + REFERENCE 후보. "중복을 막으려고 만든 추상이 호출되지 않으면 중복은 그대로 생긴다"는 형태가 재사용 가능하다. 그리고 "허용목록과 거부목록은 enum이 자라는 순간 반대로 갈라진다"도 별도 기준이다.
P3 — port 구현의 스레드 안전성 요구가 문서화되어 있지 않다
  • 사실. SchemaRegistryMessageCodecRegistry javadoc에 동시성 요구가 없다. BoundedByteSink만 "not thread-safe"를 명시한다.
  • 근거. 세 타입의 javadoc 전문.
  • 왜 문제인가. MessageCodecRegistry의 유일한 구현 RegisteredMessageCodecsMap.copyOf로 불변이라 안전하지만, 그것은 구현의 성질이지 계약이 아니다. 외부 registry를 감싸는 SchemaRegistry 구현은 브로커 소비자 스레드들에서 동시에 호출된다.
  • 확인 방법. 세 인터페이스의 javadoc 확인.
  • 후보. port javadoc에 "구현은 스레드 안전해야 한다"를 명시.
  • 다음 단계. REFERENCE 후보(port 계약은 동시성 요구를 적는다).
P3 — SchemaRegistry라는 이름이 저장소에서 두 가지를 가리킨다
  • 사실. dev.caskeleton.messaging.schema.SchemaRegistry(이 leaf의 port)와 com.networknt.schema.SchemaRegistry(JSON Schema 라이브러리)가 공존하고, 후자만 실제로 import된다.
  • 근거. evidence/raw/272 §C.
  • 왜 문제인가. 지금 깨지는 것은 없다. 다만 reachability 판정에서 실제로 오탐을 만들었다 — 단어 검색이 2건을 맞췄고 둘 다 다른 타입이었다. 사람이 같은 실수를 한다.
  • 확인 방법. git grep -n 'import .*\.SchemaRegistry;' -- src
  • 후보. 이름 변경 없이 두는 것이 합리적일 수 있다. 기록만 남긴다.
  • 다음 단계. REFERENCE 후보(도달성 판정은 단어가 아니라 import로 확인한다).
확인된 설계(문제 아님)
  • BoundedByteSink가 codec의 에러 코드를 전달하고, pre-flight가 예산을 소비하지 않는 것 — 테스트가 양쪽을 고정
  • EncodedMessage의 양방향 방어 복사와 payload를 찍지 않는 toString
  • 버전을 registry 키에 포함하고 "타입 미등록"과 "버전 미등록"을 다른 코드로 구분하는 것
  • raw-bytes 기본 금지가 클래스가 아니라 content type으로 강제되는 것

Source anchors

id kind path revision what it proves limitations
MSA-001 registry src/config/architecture/modules.json 21234e38 deps ["messaging-core-api"], memberships ["app-bootstrap"] 선언
MSA-002 build messaging-schema-api/build.gradle same vendor 의존성 0
MSA-003 code .../schema/MessageContractKey.java same 버전 키 결정과 그 이유
MSA-004 code .../schema/BoundedByteSink.java same 할당 경계, 에러 코드 전달, pre-flight 실제 인코더 동작은 각 codec leaf
MSA-005 code .../schema/EncodedMessage.java same 양방향 복사, 배열 equals, 안전한 toString
MSA-006 code .../schema/SchemaCompatibilityValidator.java same 포맷 독립 규칙과 분리 이유 호출자 없음(§12.1)
MSA-007 code .../schema/SchemaRegistry.java same port 계약, history oldest-first production 구현 없음
MSA-008 code .../schema/RawBytesMessageCodec.java same escape hatch 계약 외부 사용 0
MSA-009 code .../schema/{MessageCodec,MessageCodecRegistry,SchemaReference,SchemaCompatibility}.java same codec/식별/모드 계약
MSA-010 test src/test/java/** (3 클래스 / 19 테스트) same §10 표 순수 단위
MSA-011 cross-leaf code messaging-runtime-core/.../RegisteredMessageCodecs.java:29-77 same raw-bytes 기본 금지의 실제 강제 지점, 중복 content type 거절 해당 leaf SSOT가 소유
MSA-012 cross-leaf code messaging-schema-avro/.../AvroCompatibilityGate.java:34-61 same 중복 구현과 두 형태의 차이 해당 leaf SSOT가 소유
EVD-272 command evidence/raw/272-schema-family-reachability.txt same §12.1·§12.3 전부 정적 검색
EVD-274 command ./gradlew :messaging:messaging-schema-api:test --rerun-tasks same 19 / 0 skipped / 0 failures 순수 단위

A19-MESSAGING-SCHEMA-AVRO. messaging-schema-avro

분석 중에는 messaging/MESSAGING-SCHEMA-AVRO.md 파일이었다. 597줄.

messaging-schema-avro 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-schema-avro SSOT owner: messaging-schema-avro integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-schema-avro
  • canonical state analysisFile: §A19-MESSAGING-SCHEMA-AVRO
  • source path: src/messaging/messaging-schema-avro
  • registry allowed_dependencies: ["messaging-core-api", "messaging-schema-api"]
  • registry runtime_memberships: [] — build-only / incubating
숫자
항목
production Java 파일 2
production LOC 345
패키지 1 (dev.caskeleton.messaging.schema.avro)
test 파일 3
test 메서드(실행 확인) 16
test resource /schemas/order.created/v1.avsc
외부 의존성 1 (org.apache.avro:avro:1.12.0, api)

두 클래스: AvroMessageCodec(런타임 인코딩/디코딩), AvroCompatibilityGate(CI용 진화 검사).

Coverage ledger
scope/file group count disposition reason
.../avro/AvroMessageCodec.java 1 FULL_READ 272줄 전문
.../avro/AvroCompatibilityGate.java 1 FULL_READ 73줄 전문
src/test/java/** 3 FULL_READ 전문
src/test/resources/schemas/order.created/v1.avsc 1 STRUCTURAL_ONLY fixture 스키마; 필드 구성만 확인
build.gradle 1 FULL_READ 주석 포함 11줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

선택적(optional) Avro codec. Stable이 아니고 registry membership이 비어 있다 — build-only / incubating이며, docs/messaging/support-matrix.md의 등급과는 다른 축이다.

Avro를 api로 선언한 이유가 build.gradle 주석에 있다.

// api: AvroMessageCodec's constructors take a registry of org.apache.avro.Schema and
// AvroCompatibilityGate.check takes and compares them. A consumer cannot build that
// registry without naming the type, so hiding the dependency only stops them compiling.
api 'org.apache.avro:avro:1.12.0'

src/messaging/CLAUDE.md:40-43이 기술하는 게이트 — public/protected 시그니처에 나오는 vendor 라이브러리가 api로 선언됐는지 대조 — 를 이 leaf가 통과한다. 형제 messaging-schema-json은 Jackson 타입이 시그니처에 없으므로 implementation이고, 그 판정 차이가 규칙이 실제로 작동한다는 증거다.

클래스 둘의 실행 시점이 다르다.

클래스 언제 도는가 근거
AvroMessageCodec 런타임(메시지마다) MessageCodec 구현
AvroCompatibilityGate CI 클래스 javadoc: "Run in CI rather than at runtime"

게이트의 javadoc이 그 이유를 적는다 — "By the time a producer has published one incompatible record, the damage is durable: the record sits in a retained log that every current and future consumer must be able to read."


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), messaging-schema-api(api), avro:1.12.0(api).

나가는 것: 없다. 어떤 leaf의 allowed_dependencies에도 messaging-schema-avro가 없다. messaging-spring-boot-starter의 17개 의존 목록에도 없다.

런타임 배선: 없음. runtime_memberships: []이므로 배포 아티팩트에 실리지 않는다. bean도 없다(Spring 주석 0개).

소비자 없음과 membership 없음이 일치한다. 이것이 정합적인 incubating 상태다 — messaging-cloudevents와 대비된다(그쪽은 membership이 있고 소비자가 없다).


3. 패키지/컴포넌트 지도

AvroMessageCodec  (MessageCodec 구현)
├── encode(type, version, GenericRecord)     → EncodedMessage
├── decode(type, version, byte[], Class)     → GenericRecord   (writer == reader)
├── decodeEvolved(type, writerV, readerV, byte[]) → GenericRecord  (writer != reader)
├── schemaFor(type, version)                 → 등록 조회, 2단 에러
├── boundedReader(writer, reader)            → newArray 오버라이드
└── flatten(nested registry)                 → (type, version) 평탄화 + 깊은 복사

AvroCompatibilityGate  (CI)
└── check(candidate, history, mode)
    ├── isTransitive / readsBackward / readsForward   (private, 자체 구현)
    └── requireCompatible → org.apache.avro.SchemaCompatibility

4. 계약·불변식·상태 모델

4.1 Avro 바이너리에는 스키마가 없다 — 그래서 registry가 계약이다
// AvroMessageCodec.java:33-37
 * <p>Decoding uses an explicit writer schema and reader schema pair. Avro binary carries no schema
 * of its own, so decoding with the wrong schema does not fail  it produces plausible garbage. The
 * registry is what makes the writer schema knowable, and passing both schemas to the reader is what
 * makes evolution work: Avro resolves added, removed, and defaulted fields only when it can see
 * both sides.

"does not fail — it produces plausible garbage"가 이 leaf의 모든 방어의 전제다. JSON이나 Protobuf와 달리 Avro는 잘못된 스키마로 디코딩해도 예외를 던지지 않는 경우가 있다.

single-object encoding에 헤더를 붙이지 않는 것도 명시적 결정이다 — "The framing that would carry a schema fingerprint belongs to the transport headers, where the platform already carries schema identity for every format, rather than being duplicated inside the Avro payload for this one format."

4.2 flatten: 얕은 복사가 만든 구멍

생성자가 받는 것은 중첩 맵 Map<MessageType, Map<SchemaVersion, Schema>>이고, Map.copyOf바깥 레벨만 복사한다.

// AvroMessageCodec.java:78-82
 * <p>{@code Map.copyOf} on the outer map is a shallow copy: every inner {@code Map<SchemaVersion,
 * Schema>} stayed the caller's own object, so a caller that kept a reference could add, replace,
 * or remove a schema version after construction and the codec would silently start encoding
 * against it. Flattening to {@code (type, version)} keys copies both levels and makes the version
 * part of the identity the lookup uses rather than a second hop.

이 결함이 위험한 이유는 §4.1과 곱해진다 — 스키마가 바뀌어도 디코딩이 실패하지 않고 그럴듯한 쓰레기를 낸다.

AvroRegistryBoundsTest.mutatingTheCallersMapAfterConstructionChangesNothing이 세 가지를 한 번에 확인한다: 생성 후 추가한 버전은 미등록, 생성 후 추가한 타입도 미등록, 원래 등록한 스키마는 그대로.

평탄화가 MessageContractKey(schema-api)를 키로 쓰므로 §4.5의 2단 에러 구분도 자연히 따라온다.

4.3 인코딩: direct encoder를 쓰는 이유
// AvroMessageCodec.java:122-124
// A direct encoder, not the buffering one: the buffering encoder holds bytes back until flush,
// which would let a large record allocate freely before the sink ever sees a write. Direct
// encoding makes the bound apply to the record as it is written.
BinaryEncoder encoder = EncoderFactory.get().directBinaryEncoder(sink, null);

BoundedByteSink(schema-api)의 경계가 실제로 작동하려면 인코더가 증분적으로 써야 한다. EncoderFactory.get().binaryEncoder(...)는 버퍼링하므로 sink가 첫 write를 보기 전에 큰 레코드가 이미 할당된다. 즉 schema-api의 방어가 이 한 줄에 의존한다.

인코딩 전 검사 둘:

  • payload가 GenericRecord인가 → AVRO_PAYLOAD_NOT_A_RECORD
  • schema.equals(record.getSchema())인가 → AVRO_SCHEMA_MISMATCH

두 번째는 테스트가 이유를 적는다 — as("encoding v2 data under the v1 version would produce bytes nothing can decode").

4.4 boundedReader: 다섯 바이트 공격

이 leaf에서 가장 깊은 방어다.

// AvroMessageCodec.java:222-235
 * <p>Avro writes an array as a declared element count followed by the elements. The count is a
 * variable-length integer, so five bytes can claim four hundred million elements, and the generic
 * reader allocates the backing array from that claim before reading a single element. Bounding
 * the input length does not help: the whole hostile payload is five bytes, well under any limit,
 * and the failure is an {@code OutOfMemoryError} rather than an exception the codec could report
 *  on a consumer thread that is the process, not the message.
 *
 * <p>The ceiling is the byte limit itself. Every element costs at least one byte on the wire even
 * when it is empty, so a payload of at most {@code maxBytes} bytes cannot honestly contain more
 * than {@code maxBytes} elements, and any larger claim is a lie the reader should refuse rather
 * than reserve memory for.

구현은 익명 서브클래스의 newArray 오버라이드다.

return new GenericDatumReader<>(writerSchema, readerSchema) {
  @Override
  protected Object newArray(Object old, int size, Schema schema) {
    if (size > maxElements) {
      throw new MessageTooLargeException("AVRO_COLLECTION_TOO_LARGE", ...);
    }
    return super.newArray(old, size, schema);
  }
};

상한 선택의 논리가 정확하다. 원소 하나가 wire에서 최소 1바이트를 쓰므로, maxBytes 바이트짜리 payload가 정직하게 담을 수 있는 원소는 maxBytes개를 넘을 수 없다. 별도 튜닝 상수를 만들지 않고 이미 있는 경계에서 파생시켰다.

AvroHostileInputTest가 이 공격을 손으로 만든 zigzag varint로 재현한다.

// AvroHostileInputTest.java:118-123
 * <p>Hand-written rather than taken from an encoder because the point is to write a count with no
 * elements behind it, which no encoder will do.

그리고 공격의 크기를 직접 단언한다 — assertThat(hostile).as("the whole attack is five bytes, so no byte limit stands between it and the allocation").hasSizeLessThan(16).

테스트 클래스 javadoc이 왜 corpus가 좁은지까지 적는다.

// AvroHostileInputTest.java:30-33
 * <p>Strings, byte arrays and maps were already safe: Avro validates those lengths against the
 * bytes actually remaining. Arrays were the one shape that allocated on trust, which is why the
 * corpus below is narrow rather than exhaustive  it pins the case that failed, and the two cases
 * that must keep working around it.

이것은 "좁은 테스트"를 정당화한 드문 예다 — 다른 형태는 라이브러리가 이미 방어하므로 재확인이 아니라 잡음이 된다.

4.5 schemaFor: 2단 에러

AVRO_TYPE_NOT_REGISTERED(타입 미등록)와 AVRO_VERSION_NOT_REGISTERED(버전 미등록)를 구분한다. JSON codec의 UNKNOWN_MESSAGE_TYPE/SCHEMA_VERSION_NOT_REGISTERED와 같은 형태이지만 코드 문자열이 다르다. 두 codec이 같은 판단을 다른 어휘로 보고한다 — §12.3.

4.6 decodeEvolved: 나중에 붙은 경계
// AvroMessageCodec.java:199-201
// The same bound the ordinary decode applies. It was missing here, so the evolution path — the
// one a consumer takes for every message written by a newer producer — accepted input of any
// size.
requireWithinLimit(encoded.length);

테스트가 두 각도에서 붙든다 — AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound(as("decodeEvolved accepted input of any size"))와 AvroHostileInputTest.theEvolutionDecodeAppliesTheSameCollectionBound(as("a consumer reading a newer producer takes this path for every message")).

decodeEvolved가장 흔한 경로인데 가장 늦게 보호됐다. 진화 경로는 producer가 앞서 나간 순간부터 모든 메시지가 지나는 길이다.

4.7 AvroCompatibilityGate
public void check(Schema candidate, List<Schema> history, SchemaCompatibility mode) {
  if (mode == SchemaCompatibility.NONE_EXPERIMENTAL || history.isEmpty()) return;
  List<Schema> checked = isTransitive(mode) ? history : history.subList(0, 1);
  for (Schema previous : checked) {
    if (readsBackward(mode)) requireCompatible(candidate, previous, "backward");
    if (readsForward(mode)) requireCompatible(previous, candidate, "forward");
  }
}

historynewest first를 요구한다(javadoc @param history the previously registered schemas, newest first). 이것은 messaging-schema-apiSchemaRegistry.historyoldest first를 계약으로 삼는 것과 반대다. 두 계약을 잇는 코드가 없으므로 오늘은 충돌하지 않지만, 잇는 순간 reversed()를 빠뜨리면 조용히 잘못된 버전을 비교한다. SchemaCompatibilityValidator.versionsToCheck가 정확히 그 reversed()를 수행하고, 그 클래스는 호출되지 않는다(§12.3).

에러 코드는 방향에서 파생된다 — "AVRO_" + direction.toUpperCase(Locale.ROOT) + "_INCOMPATIBLE"AVRO_BACKWARD_INCOMPATIBLE / AVRO_FORWARD_INCOMPATIBLE.


5. 주요 실행 경로

encode: schemaForGenericRecord 확인 → 스키마 동일성 확인 → BoundedByteSink + direct encoder → writer.write + flushEncodedMessage(bytes, AVRO, SchemaReference)

decode(동일 버전): requireWithinLimitschemaFor → 대상 타입이 GenericRecord 계열인지 → boundedReader(writer, writer)reader.read

decodeEvolved: requireWithinLimitschemaFor(writer) + schemaFor(reader)boundedReader(writer, reader)reader.read

CI 게이트: check(candidate, history, mode) → 모드에 따라 비교 대상 선정 → 방향별 checkReaderWriterCompatibility


6. 실패 경로와 복구/번역

코드 예외 조건
AVRO_TYPE_NOT_REGISTERED MessageValidationException 타입 미등록
AVRO_VERSION_NOT_REGISTERED MessageValidationException 버전 미등록
AVRO_PAYLOAD_NOT_A_RECORD MessageValidationException encode/decode 대상이 GenericRecord가 아님
AVRO_SCHEMA_MISMATCH MessageValidationException payload 스키마 ≠ 등록 스키마
AVRO_PAYLOAD_TOO_LARGE MessageTooLargeException 인코딩 중 또는 디코딩 입력 상한 초과
AVRO_COLLECTION_TOO_LARGE MessageTooLargeException 배열 원소 수 주장 > maxBytes
AVRO_ENCODE_FAILED MessageSerializationException 그 외 인코딩 실패
AVRO_DECODE_FAILED MessageSerializationException 그 외 디코딩 실패
AVRO_EVOLUTION_FAILED MessageSerializationException 진화 해석 실패
AVRO_BACKWARD_INCOMPATIBLE / AVRO_FORWARD_INCOMPATIBLE MessageSchemaIncompatibleException CI 게이트

예외 재던지기 패턴이 세 곳에 반복된다.

} catch (IOException | RuntimeException failure) {
  if (failure instanceof MessageTooLargeException tooLarge) {
    throw tooLarge;
  }
  throw new MessageSerializationException("AVRO_*_FAILED", ..., failure);
}

BoundedByteSink가 던지는 MessageTooLargeExceptionRuntimeException이므로 catch에 걸린다. 그것을 그대로 통과시키지 않으면 크기 실패가 인코딩 실패로 접힌다 — JSON codec의 unwrapTooLarge와 같은 문제를 다른 방식(원인 사슬 탐색이 아니라 즉시 instanceof)으로 푼다. §12.3.

AvroHostileInputTest.aCountBeyondIntRangeFailsWhileReadingRatherThanWhileReserving가 흥미로운 경계를 잡는다 — 2³²을 주장하면 int로 잘려 무해한 값이 되고, 그 다음 읽기가 입력 부족으로 실패해 MessageSerializationException이 된다. 즉 newArray 방어를 우회하는 값이 존재하지만 그 우회는 할당이 아니라 읽기 실패로 끝난다.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

AvroMessageCodec은 불변이다 — schemasMap.copyOf된 평탄 맵, maxBytes는 int. BoundedByteSink·BinaryEncoder·DatumReader·BinaryDecoder는 전부 호출마다 새로 만들어진다.

EncoderFactory.get()/DecoderFactory.get()은 Avro의 싱글턴 팩토리이고 스레드 안전하다. 다만 binaryDecoder(encoded, null)의 두 번째 인자가 재사용 decoder 자리인데 항상 null을 넘긴다 — 재사용하지 않으므로 공유 상태가 없다. 성능을 버리고 안전을 택한 형태다.

AvroCompatibilityGate는 상태가 없다.


8. 설정·기능 플래그·환경 차이

설정 없음.

상수 가시성
AvroMessageCodec.DEFAULT_MAX_BYTES 1,048,576 private

private이므로 §12.3의 "1 MiB가 다섯 곳에 복사됨" 문제에서 이 leaf는 외부에 값을 노출하지 않는다. 대신 공유 상수를 읽지도 않는다.

Avro 버전은 1.12.0으로 build.gradle에 고정돼 있다.


9. 퍼시스턴스/외부 시스템 세부

없다. 외부 schema registry를 쓰지 않는다 — 스키마는 생성자 인자로 받는다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-schema-avro:test. BUILD SUCCESSFUL, 16 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
AvroCompatibilityTest 8 round trip, schema reference, v1→v2 default를 통한 진화, defaulted 필드 추가는 backward 호환, default 없는 추가는 거절, payload 스키마 불일치 사전 거절, 미등록 버전/타입 거절 transitive 모드 실제 동작(테스트가 BACKWARD만 씀)
AvroHostileInputTest 4 4억 원소 주장이 할당 전에 거절됨, 진화 경로도 같은 방어, int 범위 초과는 읽기 실패로 끝남, 정직한 배열은 정상 문자열·맵·바이트 배열(라이브러리가 이미 방어한다고 javadoc이 명시)
AvroRegistryBoundsTest 4 생성 후 맵 변경이 무효, 인코딩 중 거절(refused at byte), 진화 경로 상한, 정확히 상한인 payload 허용

증명 공백 하나. AvroCompatibilityGate의 transitive 모드가 테스트되지 않는다. 8개 중 게이트를 부르는 것은 둘이고 둘 다 SchemaCompatibility.BACKWARD(pairwise)다. isTransitive가 true인 경로 — history 전체를 순회하는 분기 — 는 실행되지 않는다. 그 분기는 §12.3이 지적하는 중복 구현의 핵심이기도 하다.

세 테스트 클래스 중 둘이 클래스 javadoc으로 이전 결함을 서술한다(AvroHostileInputTest, AvroRegistryBoundsTest). 이 저장소의 일관된 습관이다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api","messaging-schema-api"]
verifyRuntimeModuleMembership [] — 런타임 편입 없음이 강제됨
vendor api 규칙(src/messaging/CLAUDE.md:40-43) Avro가 public 시그니처에 등장 → api 선언 필요. 통과
ArchUnit 전용 규칙 없음

AvroCompatibilityGate가 "Run in CI"라고 선언하지만, 이 저장소의 CI에서 그것을 실행하는 task가 없다. src/build.gradle의 9개 verifyMessaging* task는 전부 app-bootstrap/build/messaging-evidence/**/manifest.json을 요구하는 자격 게이트이고 스키마 진화 검사를 부르지 않는다. §12.1.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/272-schema-family-reachability.txt.

12.1 Public surface reachability
타입 leaf 밖 참조 판정
AvroMessageCodec 0 소비자 없음
AvroCompatibilityGate 0 소비자 없음

git grep -l -w AvroMessageCodec -- src ':!src/messaging/messaging-schema-avro' exit 1, AvroCompatibilityGate도 동일.

두 클래스의 "0"은 성격이 다르다.

AvroMessageCodec의 0은 정합적이다 — runtime_memberships: []이고 starter의 codec registry에도 등록되지 않는다(RegisteredMessageCodecs.of(JacksonMessageCodec.of(...)), varargs 비어 있음). 소비자 없음과 배포 없음이 일치한다.

AvroCompatibilityGate의 0은 다르다. 이 클래스는 런타임이 아니라 CI에서 도는 것을 전제로 설계됐다. javadoc이 그렇게 선언한다. 그런데 그것을 부르는 CI task가 없다. 즉 "런타임에 안 쓰이는 건 당연하다"가 이 클래스에는 적용되지 않는다 — 이 클래스는 애초에 런타임 소비자를 가질 계획이 없었고, 계획된 소비자(CI)도 없다.

이 구분이 중요한 이유: 배포 게이트가 생겨 messaging-schema-avro가 런타임에 편입되면 AvroMessageCodec은 자연히 배선되지만 AvroCompatibilityGate는 여전히 아무 데도 붙지 않는다. 두 문제는 함께 풀리지 않는다.

한계. 이 저장소는 템플릿이고, 파생 프로젝트가 AvroCompatibilityGate를 자기 CI에서 부를 수 있다. 그것을 확인할 수단이 저장소 안에 없다.

12.2 Conditional sibling comparison

Spring 주석 0개. bean 없음. 비교 대상 없음.

codec sibling 비교는 가능하고 결과가 유의미하다.

codec MessageCodec 구현 starter 등록 membership 정합성
JacksonMessageCodec o o ["app-bootstrap"] 일치
AvroMessageCodec o x [] 일치
ProtobufMessageCodec o x [] 일치
RawBytesMessageCodec o x ["app-bootstrap"](schema-api 소속) 불일치

Avro는 세 축이 전부 "없음"으로 정렬돼 있다. incubating leaf가 이래야 하는 형태다.

12.3 Duplicate mechanism sweep

(a) 진화 판단 중복 — 확인됨

AvroCompatibilityGate의 private isTransitive/readsBackward/readsForwardmessaging-schema-apiSchemaCompatibilityValidator의 public static isTransitive/checksBackward/checksForward와 같은 판단을 다시 구현한다.

판단 schema-api 이 leaf
isTransitive public static, 허용목록 private static, 글자까지 동일한 복사본
후방 검사 checksBackward, 허용목록 readsBackward, 거부목록
전방 검사 checksForward, 허용목록 readsForward, 거부목록

현재 enum 7개 값에서 두 구현의 결과는 같다(NONE_EXPERIMENTALcheck:34의 early return이 가린다). 형태가 반대이므로 enum이 자라면 갈라진다 — 허용목록은 새 모드를 "검사 안 함"으로, 거부목록은 "양방향 검사"로 기본 처리한다.

schema-api의 javadoc이 이 중복을 정확히 예고했다 — "duplicating that reasoning in each codec is how the two formats drift apart". 그리고 그것을 막을 클래스는 호출되지 않는다. 상세는 §A19-MESSAGING-SCHEMA-API §12.3이 소유한다.

(b) history 순서 계약이 반대다

위치 요구
SchemaRegistry.history (schema-api) oldest first
AvroCompatibilityGate.checkhistory 파라미터 newest first

둘을 잇는 코드가 없어 오늘은 충돌하지 않는다. 잇는 순간 reversed()를 빠뜨리면 history.subList(0, 1)이 가장 오래된 스키마를 "직전 버전"으로 비교한다. 실패하지 않고 엉뚱한 비교를 통과시킬 수 있다.

(c) 크기 예외 통과 패턴이 codec마다 다르다

codec 방식
JacksonMessageCodec unwrapTooLarge — 원인 사슬을 끝까지 훑음
AvroMessageCodec catch 안에서 즉시 instanceof (3곳 반복)
ProtobufMessageCodec 해당 없음 — requireFits로 사전 거절

같은 문제(BoundedByteSinkMessageTooLargeException이 포맷 라이브러리 예외에 삼켜지는 것)를 세 가지로 푼다. Jackson은 예외를 감싸므로 사슬 탐색이 필요하고, Avro는 감싸지 않으므로 즉시 검사로 충분하다 — 즉 차이가 라이브러리 동작에서 나온 정당한 것이다. 다만 그 이유가 어디에도 적혀 있지 않다.

(d) 에러 코드 어휘가 codec마다 다르다

같은 판단에 다른 문자열:

판단 JSON Avro Protobuf
타입 미등록 UNKNOWN_MESSAGE_TYPE AVRO_TYPE_NOT_REGISTERED UNKNOWN_MESSAGE_TYPE
버전 미등록 SCHEMA_VERSION_NOT_REGISTERED AVRO_VERSION_NOT_REGISTERED SCHEMA_VERSION_NOT_REGISTERED
타입 불일치 PAYLOAD_TYPE_MISMATCH AVRO_PAYLOAD_NOT_A_RECORD / AVRO_SCHEMA_MISMATCH PAYLOAD_TYPE_MISMATCH

JSON과 Protobuf는 어휘를 공유하고 Avro만 접두사를 붙인다. 대시보드가 코드로 집계하면 Avro만 별도 계열이 된다.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
AvroCompatibilityGate javadoc: "Run in CI rather than at runtime" 저장소 CI에 호출 지점 없음 미실현 — 진술이 틀린 게 아니라 계획이 실행되지 않음
build.gradle 주석: Avro가 public 시그니처에 등장하므로 api 두 클래스의 public 시그니처에 org.apache.avro.Schema 등장 확인 일치
docs/messaging/support-matrix.md: Avro가 Stable이 아님 membership [], starter 미등록 일치
docs/messaging/support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 실제로 []이 leaf에 한해서는 맞다 family 전체로는 틀림(messaging-core-api §12.4)

마지막 행이 흥미롭다. 잘못된 일반화가 우연히 이 leaf에서는 참이 된다. 그래서 이 문서만 읽으면 drift를 발견할 수 없다 — family 수준에서 세야 보인다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

테스트 클래스 javadoc이 세 결함을 보존한다.

위치 이전 상태 그것이 만든 실패
AvroRegistryBoundsTest javadoc 중첩 맵에 Map.copyOf(얕은 복사) 호출자가 생성 후 스키마 교체 가능 → Avro는 실패하지 않고 그럴듯한 쓰레기를 만듦
AvroRegistryBoundsTest javadoc decodeEvolved에 크기 검사 없음 producer가 앞서 나간 뒤 모든 메시지가 지나는 경로가 무제한 입력을 수용
AvroHostileInputTest javadoc 배열 원소 수 주장을 신뢰하고 할당 5바이트로 4억 원소 배열 → OutOfMemoryError, codec이 분류할 수 없는 실패, consumer 스레드에서 프로세스 사망
AvroMessageCodec.decodeEvolved 주석 같은 내용

세 번째가 형태상 가장 흥미롭다 — 바이트 상한이라는 올바른 도구가 잘못된 공격에 적용되어 있었다. 테스트 javadoc이 그것을 한 문장으로 적는다: "The byte limit is the wrong instrument for this attack and was the only one in place."


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-272 command evidence/raw/272-schema-family-reachability.txt §B, §D, §E 두 형태의 진화 판단 나란히, codec별 소비자 0, membership [] 정적 검색
EVD-276 command ./gradlew :messaging:messaging-schema-avro:test --rerun-tasks BUILD SUCCESSFUL, 16 / 0 / 0 실제 Avro 브로커 없음

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • Avro 바이너리에 스키마가 없어 registry가 계약이 되는 이유 — 클래스 javadoc
  • single-object encoding에 헤더를 안 붙이는 이유 — 클래스 javadoc
  • 얕은 복사가 만든 구멍과 평탄화로 고친 이유 — flatten javadoc
  • direct encoder를 쓰는 이유 — encode 주석
  • 배열 원소 상한을 maxBytes로 잡은 논리 — boundedReader javadoc
  • 적대적 입력 corpus가 좁은 이유 — AvroHostileInputTest javadoc
  • 게이트가 CI용인 이유 — AvroCompatibilityGate javadoc
  • Avro를 api로 선언한 이유 — build.gradle 주석

추론

  • 크기 예외 통과 방식이 JSON과 다른 것은 Jackson이 예외를 감싸고 Avro는 감싸지 않기 때문이다 → 추론. 두 코드의 형태는 관측이고 인과는 추론이다.
  • 에러 코드에 AVRO_ 접두사를 붙인 것이 의도인지 → 미상.
  • 게이트가 newest first를 요구하는 것과 port가 oldest first인 것 중 어느 쪽이 나중인지 → 미상. 커밋이 4개뿐이고 둘 다 같은 커밋에 들어왔다.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 두 클래스 345줄 전문의 계약과 방어
  • 16개 테스트가 통과하고 무엇을 단언하는지
  • 소비자 0과 membership []이 정합적이라는 것
  • 진화 판단이 schema-api와 중복이고 형태가 반대라는 것
  • history 순서 계약이 schema-api와 반대라는 것
  • CI 실행을 전제한 게이트를 부르는 CI task가 없다는 것

확인하지 못한 것

  • transitive 모드의 실제 동작. 테스트가 BACKWARD만 쓴다. history 전체 순회 분기가 실행된 적이 없다.
  • 파생 프로젝트가 AvroCompatibilityGate를 자기 CI에서 부르는지. 저장소 안에 확인 수단이 없다.
  • 실제 Avro 스키마 진화 사례에서 checkReaderWriterCompatibility의 판정이 이 게이트의 방향 매핑과 맞는지 — 테스트는 defaulted 필드 추가/미추가 두 경우만 본다.
  • decodeEvolved가 실제 다중 버전 배포에서 어떤 빈도로 쓰이는지. 소비자가 없어 관측할 수 없다.

17. 손볼 것

P2 — CI에서 돈다고 선언한 게이트를 부르는 CI가 없다
  • 사실. AvroCompatibilityGate javadoc이 "Run in CI rather than at runtime"이라고 선언한다. 저장소 전체에서 이 클래스 참조는 자기 선언과 자기 테스트뿐이고, src/build.gradle의 9개 verifyMessaging* task 중 스키마 진화를 검사하는 것이 없다.
  • 근거. evidence/raw/272 §D. src/build.gradle:65-110.
  • 왜 문제인가. 게이트의 존재 이유가 "한 번 발행되면 보존 로그에 영구히 남는다"인데, 그 보호가 어느 파이프라인에도 붙어 있지 않다. AvroMessageCodec의 미사용과 달리 이것은 membership으로 설명되지 않는다 — 런타임 편입 여부와 무관하게 CI 게이트는 붙었어야 한다.
  • 확인 방법. git grep -n -w AvroCompatibilityGate -- src · git grep -n 'verifyMessaging' -- src/build.gradle
  • 후보. (a) 스키마 디렉터리를 읽어 게이트를 돌리는 Gradle task를 만든다. (b) 파생 프로젝트가 붙이는 확장점이라면 javadoc이 그렇게 말하도록 고친다.
  • 다음 단계. CASE 후보. "장치는 있고 회로가 닫히지 않았다"의 전형이고, 재현이 정적 검색으로 끝난다.
P2 — 진화 판단이 두 곳에 있고 형태가 반대다
  • 사실. isTransitiveSchemaCompatibilityValidator(public static)와 이 leaf(private static)에 글자까지 같은 복사본이 있다. 방향 판정은 전자가 허용목록, 후자가 거부목록이다.
  • 근거. evidence/raw/272 §B에 두 형태가 나란히 출력된다.
  • 왜 문제인가. 오늘 7개 모드에서 결과는 같지만 형태가 반대이므로 SchemaCompatibility에 값이 추가되는 순간 갈라진다 — 허용목록은 "검사 안 함", 거부목록은 "양방향 검사". 그리고 이 중복은 schema-api의 javadoc이 명시적으로 막으려던 것이다.
  • 확인 방법. evidence/raw/272 §B 재실행.
  • 후보. AvroCompatibilityGateSchemaCompatibilityValidator의 public static을 부르게 한다. 세 메서드 다 이미 public static이다.
  • 다음 단계. messaging-schema-api §17의 같은 항목과 동일 사건이다. 그 leaf가 소유하고 여기서는 교차 참조만 남긴다.
P3 — history 순서 계약이 port와 게이트에서 반대다
  • 사실. SchemaRegistry.history javadoc은 oldest first, AvroCompatibilityGate.check@param history는 newest first.
  • 근거. 두 javadoc.
  • 왜 문제인가. 둘을 잇는 코드가 없어 지금은 무해하다. 이으면서 reversed()를 빠뜨리면 pairwise 모드가 가장 오래된 스키마를 직전 버전으로 비교한다. 실패하지 않고 통과할 수 있는 오류다. port javadoc이 이미 같은 위험을 경고한다 — "an ordering mistake here silently converts a transitive check into a pairwise one."
  • 확인 방법. 두 javadoc 대조.
  • 후보. 게이트도 oldest-first를 받게 통일하고 내부에서 뒤집는다.
  • 다음 단계. REFERENCE 후보(컬렉션 순서가 계약이면 양쪽에서 같은 방향으로 적는다).
P3 — transitive 분기가 테스트되지 않는다
  • 사실. AvroCompatibilityTest의 게이트 호출 2건이 모두 SchemaCompatibility.BACKWARD다. isTransitive가 true인 경로가 실행되지 않는다.
  • 근거. AvroCompatibilityTest.java:134-150.
  • 왜 문제인가. transitive 모드는 "여러 릴리스 뒤처진 consumer"를 위한 것이고 그것이 이 게이트의 존재 이유 중 절반이다. 그리고 그 분기가 §12.3의 중복 구현이 갈라질 지점이다.
  • 확인 방법. 두 테스트의 모드 인자 확인.
  • 후보. v1·v2·v3 세 스키마로 BACKWARD_TRANSITIVE 케이스를 추가한다.
  • 다음 단계. REFERENCE 후보(모드 enum을 분기 조건으로 쓰면 각 분기에 테스트를 둔다).
P3 — 에러 코드 어휘가 형제 codec과 갈라진다
  • 사실. 같은 판단에 JSON/Protobuf는 UNKNOWN_MESSAGE_TYPE·SCHEMA_VERSION_NOT_REGISTERED, Avro는 AVRO_TYPE_NOT_REGISTERED·AVRO_VERSION_NOT_REGISTERED를 쓴다.
  • 근거. 세 codec의 requireRegistered/schemaFor.
  • 왜 문제인가. FailureDescriptor.code는 "stable, machine-readable code"이고 대시보드·재시도 정책이 이것으로 집계한다. 같은 판단이 두 어휘로 나뉘면 Avro만 별도 계열이 된다.
  • 확인 방법. git grep -n 'NOT_REGISTERED' -- 'src/messaging/**/*.java'
  • 후보. 공통 코드를 쓰고 포맷은 sanitizedMessage로 구분한다.
  • 다음 단계. REFERENCE 후보(안정 코드는 판단 단위로 정하고 구현 단위로 정하지 않는다).
확인된 설계(문제 아님)
  • 중첩 registry를 (type, version)으로 평탄화해 양쪽 레벨을 복사하는 것
  • direct encoder 선택 — BoundedByteSink의 경계가 실제로 작동하기 위한 전제
  • 배열 원소 상한을 별도 튜닝 값이 아니라 maxBytes에서 파생시킨 것
  • decodeEvolved에 같은 상한을 적용한 것과, 그것을 두 각도에서 붙드는 테스트
  • 적대적 입력 corpus를 좁게 두고 그 이유를 적은 것
  • Avro를 api로 선언한 것(형제 JSON과 반대 판정이고, 그것이 맞다)
  • 소비자 0과 membership []이 정합적인 것

Source anchors

id kind path revision what it proves limitations
MSV-001 registry src/config/architecture/modules.json 21234e38 deps, runtime_memberships: [] 선언
MSV-002 build messaging-schema-avro/build.gradle same Avro api 선언과 그 이유, 버전 1.12.0
MSV-003 code .../avro/AvroMessageCodec.java 전문 same §4.14.6
MSV-004 code .../avro/AvroCompatibilityGate.java 전문 same §4.7, §12.3(a)
MSV-005 test AvroCompatibilityTest (8) same round trip·진화·게이트 pairwise transitive 미검증
MSV-006 test AvroHostileInputTest (4) same 5바이트 4억 원소 공격과 방어, 진화 경로 동일 방어 문자열·맵은 범위 밖(javadoc이 이유를 적음)
MSV-007 test AvroRegistryBoundsTest (4) same 생성 후 맵 변경 무효, 인코딩 중 거절, 진화 경로 상한
MSV-008 cross-leaf code messaging-schema-api/.../SchemaCompatibilityValidator.java:79-112 same 중복의 다른 쪽 해당 leaf SSOT가 소유
MSV-009 cross-leaf code messaging-schema-api/.../SchemaRegistry.java:16-17 same oldest-first 계약 해당 leaf SSOT가 소유
MSV-010 cross-leaf code messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:360-366 same codec registry에 Avro 미등록 해당 leaf SSOT가 소유
MSV-011 build policy src/build.gradle:65-110, src/messaging/CLAUDE.md:40-43 same verifyMessaging* 9개가 스키마 진화를 부르지 않음, vendor api 규칙
EVD-272 command evidence/raw/272-schema-family-reachability.txt same §12.1·§12.3 정적 검색
EVD-276 command ./gradlew :messaging:messaging-schema-avro:test --rerun-tasks same 16 / 0 / 0 실제 브로커 없음

A19-MESSAGING-SCHEMA-JSON. messaging-schema-json

분석 중에는 messaging/MESSAGING-SCHEMA-JSON.md 파일이었다. 511줄.

messaging-schema-json 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-schema-json SSOT owner: messaging-schema-json integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-schema-json
  • canonical state analysisFile: §A19-MESSAGING-SCHEMA-JSON
  • source path: src/messaging/messaging-schema-json
  • registry allowed_dependencies: ["messaging-core-api", "messaging-schema-api"]
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 1
production LOC 226
패키지 1 (dev.caskeleton.messaging.schema.json)
test 파일 3
test 메서드(실행 확인) 18
외부 의존성 1 (tools.jackson.core:jackson-databind, implementation)

이 leaf는 클래스 하나다: JacksonMessageCodec. 그리고 messaging 플랫폼에서 production 소비자를 가진 유일한 codec이다(§12.1).

Coverage ledger
scope/file group count disposition reason
.../json/JacksonMessageCodec.java 1 FULL_READ 226줄 전문
src/test/java/** 3 FULL_READ 전문
build.gradle 1 FULL_READ 8줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

Stable JSON codec 하나. MessageCodec(schema-api)을 구현하고 Jackson 3(tools.jackson.* 네임스페이스)을 쓴다.

javadoc이 "기본 codec으로 노출해도 안전한 이유" 셋을 명시한다.

// JacksonMessageCodec.java:29-37
 * <p>Three things make this safe to expose as the default. The message-type registry is closed, so
 * a payload class only becomes reachable when someone registered it. The parser is constrained on
 * depth, document length, and duplicate keys, so a hostile document cannot exhaust the consumer
 * before the handler ever runs. And the encoded size is checked against the destination limit here
 * rather than at the broker, so an oversized payload fails locally with {@code NOT_TRANSMITTED}
 * evidence instead of ambiguously mid-flight.
 *
 * <p>Polymorphic default typing is never enabled. It is the mechanism behind most JSON
 * deserialization gadget chains, and no legitimate message contract needs it.

세 번째가 messaging-core-api의 3상태 발행 결과와 직접 연결된다 — 크기 초과를 브로커가 아니라 여기서 잡으면 NOT_TRANSMITTED 증거가 붙은 REJECTED가 되고, 브로커에서 잡히면 AMBIGUOUS가 된다. 전자는 버려도 안전하고 후자는 아니다.

Jackson 의존성은 implementation이다 — public 시그니처에 Jackson 타입이 없기 때문이다. 형제 leaf(schema-avro, schema-protobuf, cloudevents)는 vendor 타입이 public 시그니처에 나오므로 api로 선언했고 build.gradle에 그 이유를 주석으로 적었다. src/messaging/CLAUDE.md:40-43의 게이트가 이 구분을 강제한다.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), messaging-schema-api(api), jackson-databind(implementation).

나가는 것: messaging-spring-boot-starter(registry allowed_dependencies에 포함).

실제 배선 지점이 하나 있다 — 이 플랫폼에서 유일하게 조립되는 codec이다.

// messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:360-366
@ConditionalOnMissingBean(dev.caskeleton.messaging.schema.MessageCodecRegistry.class)
public dev.caskeleton.messaging.runtime.RegisteredMessageCodecs messagingCodecs(
    ObjectProvider<MessageContracts> contracts) {
  return dev.caskeleton.messaging.runtime.RegisteredMessageCodecs.of(
      dev.caskeleton.messaging.schema.json.JacksonMessageCodec.of(
          contracts.getIfAvailable(MessageContracts::none).byKey()));
}

RegisteredMessageCodecs.of(defaultCodec, codecs...)의 varargs 자리가 비어 있다. 즉 출하 구성의 codec registry에는 JSON 하나만 들어간다. Avro·Protobuf·raw bytes는 등록되지 않는다.

두 번째 배선 지점은 상수 참조다.

// 같은 파일 :410-413
new dev.caskeleton.messaging.policy.PayloadPolicy(
    JacksonMessageCodec.DEFAULT_MAX_BYTES,
    JacksonMessageCodec.DEFAULT_MAX_BYTES / 2),

payload 정책의 상한이 JSON codec의 상수에서 파생된다. 포맷 중립이어야 할 admission 정책이 한 포맷의 클래스 상수를 참조한다 — §17에서 다룬다.

contracts.getIfAvailable(MessageContracts::none)이 기본값이므로, 애플리케이션이 MessageContracts bean을 내놓지 않으면 빈 registry로 codec이 만들어진다. 그 codec은 모든 encode/decodeUNKNOWN_MESSAGE_TYPE으로 거절한다.


3. 패키지/컴포넌트 지도

클래스 하나, 공개 표면 6개.

멤버 종류 용도
DEFAULT_MAX_BYTES = 1,048,576 public 상수 starter의 payload 정책이 참조
MAX_NESTING_DEPTH = 100 public 상수 파서 깊이 상한
of(Map) factory 기본 1 MiB
of(Map, int) factory 명시 상한
testingDefault(MessageType, Class) factory 단일 계약, v1
testingDefault(MessageType, SchemaVersion, Class) factory 단일 계약, 명시 버전

private 상수 둘: MAX_STRING_CHARACTERS = 5,000,000, MAX_NUMBER_DIGITS = 1,000.


4. 계약·불변식·상태 모델

4.1 파서 강화 — strictMapper
// JacksonMessageCodec.java:207-225
JsonFactory factory =
    JsonFactory.builder()
        .streamReadConstraints(
            StreamReadConstraints.builder()
                .maxNestingDepth(MAX_NESTING_DEPTH)        // 100
                .maxDocumentLength(maxBytes)               // = codec 상한
                .maxNumberLength(MAX_NUMBER_DIGITS)        // 1,000
                .maxStringLength(MAX_STRING_CHARACTERS)    // 5,000,000
                .maxNameLength(MAX_STRING_CHARACTERS)
                .build())
        .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
        .build();
return JsonMapper.builder(factory)
    .enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY)
    .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
    .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    .build();

여섯 가지 방어가 한 곳에 있다.

설정 막는 것
maxNestingDepth(100) 중첩 폭탄으로 파서 스택 소진
maxDocumentLength(maxBytes) 문서 길이 — codec 상한과 동일
maxNumberLength(1000) 초대형 BigDecimal 파싱 비용
maxStringLength/maxNameLength 단일 토큰 메모리
STRICT_DUPLICATE_DETECTION + FAIL_ON_READING_DUP_TREE_KEY 중복 키 — 파서마다 "먼저/나중 승리"가 달라 파싱 차이 공격이 됨
FAIL_ON_TRAILING_TOKENS 문서 뒤 추가 JSON — 두 번째 문서를 조용히 무시하는 것
FAIL_ON_UNKNOWN_PROPERTIES 미등록 필드

그리고 polymorphic default typing을 켜지 않는다. javadoc이 그것이 대부분의 JSON gadget chain의 기반이라고 적는다.

maxDocumentLengthmaxBytes와 같다는 점이 중요하다 — 인코딩 상한과 디코딩 파서 상한이 하나의 값에서 나온다. 따로 두면 둘이 갈라진다.

4.2 인코딩 — 스트리밍 경계
BoundedByteSink sink = BoundedByteSink.of(maxBytes, "PAYLOAD_TOO_LARGE");
try {
  mapper.writeValue(sink, payload);
} catch (JacksonException exception) {
  throw unwrapTooLarge(exception);
}

주석이 이유를 적는다 — "Jackson writes incrementally, so a payload whose serialized form is far larger than the limit stops at the limit instead of after the whole graph has been rendered into a buffer nobody bounded."

unwrapTooLarge가 필요한 이유도 명시돼 있다.

// :190-196
 * <p>Jackson wraps stream failures, so the size refusal would otherwise reach the caller as
 * {@code JSON_ENCODE_FAILED}  indistinguishable from a payload the mapper genuinely could not
 * render, and the two need different operator responses.

for (Throwable cause = exception; cause != null; cause = cause.getCause()) — 원인 사슬을 끝까지 훑어 MessageTooLargeException을 찾는다. 못 찾으면 MessageSerializationException("JSON_ENCODE_FAILED").

4.3 registry 조회 — 세 갈래 결과
private Class<?> requireRegistered(MessageType type, SchemaVersion version) {
  MessageContractKey key = new MessageContractKey(type, version);
  Class<?> registered = registry.get(key);
  if (registered != null) return registered;
  boolean typeIsKnown = registry.keySet().stream().anyMatch(known -> known.type().equals(type));
  if (typeIsKnown) {
    // Deliberately not falling back to another version's class: decoding v999 bytes with the v1
    // class is exactly the silent type confusion the version-keyed registry exists to stop.
    throw new MessageValidationException("SCHEMA_VERSION_NOT_REGISTERED", ...);
  }
  throw new MessageValidationException("UNKNOWN_MESSAGE_TYPE", ...);
}

SCHEMA_VERSION_NOT_REGISTERED 메시지에는 registeredVersions(type)가 정렬되어 포함된다. 테스트가 그 내용을 직접 단언한다 — hasMessageContaining("order.created v999").hasMessageContaining("[1, 2]")(JsonContractRegistryTest.java:58-61). 운영자가 "1과 2는 있고 999는 없다"를 에러 메시지만으로 알 수 있다.

4.4 인코딩·디코딩의 타입 검사 비대칭
방향 검사
encode registered.isInstance(payload)하위 타입 허용
decode registered.equals(payloadType)정확 일치 요구

비대칭이 합리적이다. 인코딩에서 OrderCreated의 하위 타입을 넘기면 Jackson이 등록된 형태로 직렬화한다. 디코딩에서 하위 타입을 허용하면 등록된 계약과 다른 클래스로 역직렬화되므로 정확 일치여야 한다. 다만 이 비대칭은 주석으로 설명되지 않았다 — §15의 추론 항목이다.

4.5 디코딩의 이중 상한
if (encoded.length > maxBytes) { throw new MessageTooLargeException("PAYLOAD_TOO_LARGE", ...); }
...
return mapper.readValue(encoded, payloadType);

명시 검사 하나(encoded.length)와 파서 내부 검사 하나(maxDocumentLength)가 겹친다. 중복이지만 둘의 실패 형태가 다르다 — 전자는 MessageTooLargeException, 후자는 JacksonExceptionMessageSerializationException. 명시 검사가 있어야 크기 초과가 크기 초과로 보고된다.

4.6 EncodedMessage에 붙는 schema reference
return new EncodedMessage(
    sink.toByteArray(), ContentType.JSON, Optional.of(SchemaReference.of(type.value(), version)));

subject가 message type 값이고 URI는 없다. 즉 이 codec은 외부 schema registry를 쓰지 않고 "타입 이름 + 버전"을 스키마 신원으로 삼는다. 테스트가 확인한다(JacksonMessageCodecTest.encodedMessageCarriesTheSchemaReference).


5. 주요 실행 경로

encode: requireRegistered(type, version) → payload가 등록 타입의 인스턴스인지 → BoundedByteSink 생성 → mapper.writeValue(sink, payload) → 실패 시 unwrapTooLargeEncodedMessage(bytes, JSON, SchemaReference)

decode: requireRegistered(type, version) → 요청 클래스가 등록 클래스와 정확히 같은지 → encoded.length 상한 → mapper.readValueJacksonException이면 JSON_DECODE_FAILED


6. 실패 경로와 복구/번역

코드 예외 조건 retryable
UNKNOWN_MESSAGE_TYPE MessageValidationException 타입 자체 미등록 false
SCHEMA_VERSION_NOT_REGISTERED MessageValidationException 타입은 알고 버전 미등록 false
PAYLOAD_TYPE_MISMATCH MessageValidationException encode: 인스턴스 아님 / decode: 클래스 불일치 false
PAYLOAD_TOO_LARGE MessageTooLargeException 인코딩 중 한도 초과 또는 디코딩 입력 초과 false
JSON_ENCODE_FAILED MessageSerializationException 그 외 Jackson 인코딩 실패 false
JSON_DECODE_FAILED MessageSerializationException 파싱 실패(깊이·중복키·trailing·미지 필드 포함) false

전부 retryable = false다 — PERMANENT_BUSINESSDESERIALIZATION 카테고리다. 같은 바이트를 다시 디코딩해도 같은 결과이므로 일관적이다.

진단 손실 하나. 파서 강화가 잡는 여섯 가지(깊이, 중복 키, trailing token, 미지 필드, 문서 길이, 토큰 길이)가 전부 하나의 코드 JSON_DECODE_FAILED로 접힌다. 운영자는 "JSON 디코딩 실패"만 보고 원인 여섯 갈래를 구분할 수 없다. 원인 예외가 cause로 붙지만 FailureDescriptorexceptionTypeOptional.empty()로 둔다(MessageSerializationException의 3인자 생성자 경로). §17 참조.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

동시성: JacksonMessageCodec은 불변이다 — registryMap.copyOf, maxBytes는 int, mapper는 빌드 후 재구성되지 않는 Jackson ObjectMapper(스레드 안전). BoundedByteSink는 매 encode마다 새로 만들어지므로 공유되지 않는다.

PlatformOverheadPerformanceTest.aRoundTripDoesNotAllocateAGrowingRetainedSet이 codec이 메시지별 상태를 보유하지 않음을 간접 확인한다(메시지당 유지 메모리 64바이트 미만).


8. 설정·기능 플래그·환경 차이

설정 파일 없음. 상수:

상수 가시성
DEFAULT_MAX_BYTES 1,048,576 public — starter가 참조
MAX_NESTING_DEPTH 100 public
MAX_STRING_CHARACTERS 5,000,000 private
MAX_NUMBER_DIGITS 1,000 private

maxBytes는 생성자 인자로 재정의 가능하고 파서의 maxDocumentLength가 그 값을 따라간다.


9. 퍼시스턴스/외부 시스템 세부

없다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-schema-json:test. BUILD SUCCESSFUL, 18 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
JacksonMessageCodecTest 9 round trip, 1 MiB 초과 거절, 미등록 타입, payload 타입 불일치, trailing token, 미지 필드, 중복 키, 깊이 200 거절, schema reference 등록 registry가 실제 배포에서 채워지는지
JsonContractRegistryTest 6 버전별 클래스 분리, v999 거절 + 등록 버전 목록 노출, 클래스/버전 짝 검사, 타입 미등록과 버전 미등록 구분, 20 MiB payload가 1,024 상한에서 멈춤, 정확히 상한인 payload 허용
PlatformOverheadPerformanceTest 3 봉투 생성 < 20µs/건, JSON 인코딩 < 50µs/건, round trip 유지 메모리 < 64 B/건 실제 처리량. 의도적으로 브로커 없음

성능 테스트의 자기 규정이 명확하다.

// PlatformOverheadPerformanceTest.java:22-30
 * <p>This measures what the platform adds  identity, validation, encoding  and nothing else.
 * There is no broker in the loop, deliberately: broker throughput is a property of the deployment
 * and varies by an order of magnitude between a laptop and a cluster, so asserting on it produces a
 * test that fails for reasons nobody can act on.
 *
 * <p>The budgets are generous on purpose. The regression worth catching here is structural  an
 * accidental per-message reflection call, a defensive copy that became a deep copy, a validator
 * that started compiling a regex per invocation  and those cost orders of magnitude, not
 * percentages. A tight budget would instead catch a busy CI agent.

이것은 성능 테스트가 무엇을 잡으려는지 명시한 드문 예다 — 퍼센트가 아니라 자릿수 회귀. 다만 aRoundTripDoesNotAllocateAGrowingRetainedSetSystem.gc()totalMemory() - freeMemory()에 의존하므로 JVM이 GC 힌트를 무시하면 잡음이 낀다. 64 B/건이라는 여유가 그것을 흡수한다.

JsonContractRegistryTest의 클래스 javadoc이 이 codec에서 만난 두 결함을 기록한다 — 타입만으로 키를 잡았던 것과, 완성된 배열에 크기 제한을 적용했던 것.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api","messaging-schema-api"]
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙(src/messaging/CLAUDE.md:40-43) Jackson이 public 시그니처에 없으므로 implementation이 맞음 — 형제 leaf와 반대 판정
ArchUnit 전용 규칙 없음

12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/272-schema-family-reachability.txt.

12.1 Public surface reachability

JacksonMessageCodec의 leaf 밖 참조는 1개 파일이다 — messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java.

이 하나가 messaging codec 전체에서 유일한 production 소비다. 형제 비교:

codec 소비자 registry membership
JacksonMessageCodec MessagingCoreAutoConfiguration ["app-bootstrap"]
AvroMessageCodec 없음 []
ProtobufMessageCodec 없음 []
RawBytesMessageCodec 없음 (schema-api 소속, ["app-bootstrap"])
DefaultCloudEventMapper 없음 ["app-bootstrap"]

Avro·Protobuf는 소비자 없음과 membership 없음이 일치한다 — 정합적인 incubating 상태다. RawBytesMessageCodec과 CloudEvents는 어긋난다(각 leaf 문서 참조).

12.2 Conditional sibling comparison

이 leaf에는 bean이 없다. 그러나 이 leaf가 조립되는 지점의 조건은 확인했다.

@ConditionalOnMissingBean(dev.caskeleton.messaging.schema.MessageCodecRegistry.class)

즉 애플리케이션이 자기 MessageCodecRegistry를 내놓으면 JSON codec 조립이 통째로 대체된다. 그 경우 PayloadPolicy가 참조하는 JacksonMessageCodec.DEFAULT_MAX_BYTES그대로 남는다 — 정책 상한만 JSON codec의 값을 유지한다. §17 참조.

12.3 Duplicate mechanism sweep

JSON 인코딩/디코딩을 하는 다른 지점이 저장소에 여럿 있다(web adapter의 응답 직렬화, redis codec, fileserver 저널, mongo cursor 등). 그러나 그들은 다른 책임(HTTP 응답, 캐시 봉투, 로컬 저널)이고 messaging 계약을 구현하지 않는다. runtime eligibility가 겹치지 않으므로 중복 경쟁으로 분류하지 않는다.

같은 messaging family 안에서 MessageCodec을 구현하는 것은 넷이고(JSON·Avro·Protobuf·raw) content type이 서로 달라 RegisteredMessageCodecs.register가 충돌을 거절한다. 책임 분리가 명확하다.

한 가지 실질 중복이 있다. 1 MiB payload 상한이 messaging family의 production 코드 다섯 곳에서 독립적으로 선언된다.

위치 가시성
messaging-policy/PayloadPolicy.DEFAULT_MAX_BYTES:17 public 1,048,576
messaging-schema-api/RawBytesMessageCodec.DEFAULT_MAX_BYTES:21 public 1,048,576
messaging-schema-json/JacksonMessageCodec.DEFAULT_MAX_BYTES:42 public 1,048,576
messaging-schema-avro/AvroMessageCodec.DEFAULT_MAX_BYTES:46 private 1,048,576
messaging-schema-protobuf/ProtobufMessageCodec.DEFAULT_MAX_BYTES:35 private 1,048,576

테스트에도 네 곳(ClaimCheckRetentionValidatorTest:47, DestinationProfileValidatorTest:225, RabbitContractHarness:40, InMemoryMessagingHarness:31)이 같은 리터럴을 갖는다.

schema-apiRawBytesMessageCodec javadoc은 이 값을 "The default encoded byte limit shared with the Stable codecs"라고 부르는데, 실제로는 공유되지 않고 복사돼 있다. 그리고 정책 쪽에 이미 주인이 있다messaging-policyPayloadPolicy.DEFAULT_MAX_BYTES가 public 상수로 존재한다. 그런데 starter는 그것을 쓰지 않고 JacksonMessageCodec.DEFAULT_MAX_BYTES를 참조한다(§2). 같은 값의 후보가 둘 있고 배선이 덜 적절한 쪽을 골랐다.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
RawBytesMessageCodec javadoc: 1 MiB가 "Stable codec들과 공유되는" 기본 상한 네 codec에 각자 리터럴 존재, 공유 상수 없음 표현 drift — 값은 일치, "shared"는 사실이 아님
JacksonMessageCodec javadoc: polymorphic default typing 미사용 strictMapperactivateDefaultTyping 호출 없음 일치
docs/messaging/support-matrix.md의 JSON Stable 등급 이 leaf가 유일하게 조립되는 codec인 것과 정합 일치

13. Git/설계 문서에서 확인한 변화와 실패 기록

JsonContractRegistryTest 클래스 javadoc이 이 codec에서 만난 두 결함을 남겼다.

// JsonContractRegistryTest.java:20-24
 * <p>Two defects met in this codec. The registry was keyed on message type alone, so a message
 * labelled v999 was decoded with the v1 class and kept its v999 label  the compatibility gate and
 * the audit record then both described a contract that was never registered. And the size limit was
 * applied to the finished byte array, which reports an oversized payload rather than preventing
 * one.

두 결함 다 messaging-schema-api가 소유하는 타입(MessageContractKey, BoundedByteSink)으로 고쳐졌다. 즉 이 leaf에서 발견된 문제가 상위 leaf의 타입을 만들어냈다.

MessagingCoreAutoConfiguration:420-427의 주석은 이 codec이 아니라 publisher 조립 결함(MSG-INT-003)을 기록하는데, 같은 configuration 안에 있으므로 조립 이력의 맥락으로 참조할 가치가 있다 — "no configuration produced one … the starter did not depend on that leaf."


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-272 command evidence/raw/272-schema-family-reachability.txt §D, §E codec별 소비자와 registry membership 정적 검색
EVD-275 command ./gradlew :messaging:messaging-schema-json:test --rerun-tasks BUILD SUCCESSFUL, 18 / 0 / 0 브로커 없음

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 기본 codec으로 안전한 이유 셋 — 클래스 javadoc
  • polymorphic default typing 금지 — 클래스 javadoc
  • 크기 초과를 로컬에서 잡아야 NOT_TRANSMITTED가 된다 — 클래스 javadoc
  • unwrapTooLarge가 필요한 이유 — 메서드 javadoc
  • 다른 버전 클래스로 폴백하지 않는 이유 — requireRegistered 주석
  • 성능 예산이 느슨한 이유 — PlatformOverheadPerformanceTest javadoc
  • 이 codec에서 만난 두 결함 — JsonContractRegistryTest javadoc

추론

  • encode는 isInstance, decode는 equals로 비대칭인 이유 → 추론. 방향별 안전성으로 설명되지만 주석이 없다.
  • 파서 실패 여섯 갈래가 한 코드로 접힌 것이 의도인지 → 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 226줄 전문의 계약과 파서 강화 설정 전수
  • 18개 테스트가 통과하고 무엇을 단언하는지
  • 이 codec이 유일하게 조립되는 codec이라는 것과 그 조립 코드의 정확한 형태
  • payload 정책 상한이 이 codec의 public 상수에서 파생된다는 것
  • 1 MiB 상한이 네 codec에 복사돼 있다는 것

확인하지 못한 것

  • 실제 배포에서 MessageContracts bean이 채워지는지. 채워지지 않으면 codec은 모든 메시지를 UNKNOWN_MESSAGE_TYPE으로 거절한다. 이 저장소에 MessageContracts production 구현이 있는지는 starter leaf가 소유한다.
  • Jackson 3의 StreamReadConstraints가 이 값들에서 실제로 어떻게 실패하는지 — 테스트는 깊이 200과 중복 키만 확인했고 maxNumberLength·maxStringLength는 검증하지 않았다.
  • 성능 예산이 실제 CI 하드웨어에서 얼마나 여유 있는지 — 이번 실행은 통과했으나 측정값을 남기지 않았다.

17. 손볼 것

P2 — 포맷 중립 payload 정책이, 자기 상수를 두고 JSON codec의 상수를 참조한다
  • 사실. MessagingCoreAutoConfiguration:410-413new PayloadPolicy(JacksonMessageCodec.DEFAULT_MAX_BYTES, JacksonMessageCodec.DEFAULT_MAX_BYTES / 2)를 만든다. 그런데 PayloadPolicy 자신이 같은 값의 public 상수 PayloadPolicy.DEFAULT_MAX_BYTES(messaging-policy/PayloadPolicy.java:17)를 갖고 있다.
  • 근거. 두 라인, 그리고 git grep -n '1_048_576' -- 'src/messaging/**/*.java'의 production 5건.
  • 왜 문제인가. MessagingAdmissionController는 목적지의 codec이 무엇이든 지나는 관문이다. 그 상한이 한 포맷 클래스의 상수에서 나오면 두 가지가 깨진다. (1) @ConditionalOnMissingBean이 허용하는 대로 애플리케이션이 자기 MessageCodecRegistry를 내놓아 JSON codec을 대체해도, 정책은 여전히 JSON codec의 값을 읽는다. (2) 다섯 곳의 리터럴 중 하나만 바뀌면 조용히 갈라지고, RawBytesMessageCodec javadoc이 이미 "shared with the Stable codecs"라고 사실과 다르게 부르고 있다. 정책 소유자가 이미 존재하는데 배선이 그것을 지나쳤다.
  • 확인 방법. git grep -n '1_048_576' -- 'src/messaging/**/*.java' · grep -n 'DEFAULT_MAX_BYTES' src/messaging/messaging-policy/src/main/java/dev/caskeleton/messaging/policy/PayloadPolicy.java
  • 후보. starter가 PayloadPolicy.DEFAULT_MAX_BYTES를 참조하게 바꾸고, 네 codec의 기본값도 그 상수(또는 설정 프로퍼티)에서 파생시킨다.
  • 다음 단계. CASE 후보. 조립 지점이 한 줄이고 재현이 정적이며, "값은 맞는데 출처가 틀렸다"는 형태가 명확하다.
P3 — 파서 방어 여섯 갈래가 하나의 실패 코드로 접힌다
  • 사실. 깊이 초과·중복 키·trailing token·미지 필드·문서 길이·토큰 길이가 전부 JSON_DECODE_FAILED가 된다.
  • 근거. decodecatch (JacksonException) 단일 분기(JacksonMessageCodec.java:155-158).
  • 왜 문제인가. 여섯 중 셋(중복 키, trailing token, 깊이)은 적대적 입력의 신호이고 나머지는 계약 불일치다. DLQ에 쌓인 메시지를 보는 운영자가 그 둘을 구분할 수 없다. FailureDescriptor.exceptionType도 비어 있다.
  • 확인 방법. JacksonMessageCodecTest의 네 케이스가 전부 같은 예외 타입을 기대하는 것으로 확인 가능.
  • 후보. JacksonException 하위 타입별로 코드를 나누거나, 최소한 exceptionType에 원인 클래스 단순명을 채운다.
  • 다음 단계. REFERENCE 후보(실패 코드는 운영자의 다음 행동이 갈리는 지점마다 나눈다).
P3 — 빈 registry로 조립되면 모든 메시지가 거절된다
  • 사실. contracts.getIfAvailable(MessageContracts::none)이 기본값이므로 MessageContracts bean이 없으면 빈 registry로 codec이 만들어진다.
  • 근거. MessagingCoreAutoConfiguration:362-365.
  • 왜 문제인가. 그 codec은 시작에 성공하고 첫 publish에서 UNKNOWN_MESSAGE_TYPE으로 실패한다. messaging-core-api 계열의 다른 leaf에서 관측된 것과 같은 형태다 — "시작은 하고 첫 쓰기에서 실패한다."
  • 확인 방법. MessageContracts production 구현의 존재 여부를 starter leaf에서 확인해야 한다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 이 leaf 밖(messaging-spring-boot-starter)의 사실에 걸린다. 그 leaf SSOT가 답을 갖는다.
확인된 설계(문제 아님)
  • 파서 상한 여섯 가지와 polymorphic typing 금지
  • maxDocumentLength가 codec 상한과 같은 값에서 나오는 것
  • unwrapTooLarge가 원인 사슬을 훑어 크기 실패를 크기 실패로 보고하는 것
  • 미등록 버전 에러가 등록된 버전 목록을 포함하는 것
  • Jackson을 implementation으로 선언한 것(형제 leaf와 반대이고, 그것이 맞다)

Source anchors

id kind path revision what it proves limitations
MSJ-001 registry src/config/architecture/modules.json 21234e38 deps, memberships ["app-bootstrap"] 선언
MSJ-002 build messaging-schema-json/build.gradle same Jackson이 implementation
MSJ-003 code .../json/JacksonMessageCodec.java 전문 same §4 전체
MSJ-004 test JacksonMessageCodecTest (9) same 파서 방어와 registry 거절 브로커 없음
MSJ-005 test JsonContractRegistryTest (6) same 버전 키 동작, 20 MiB가 1 KiB 상한에서 멈춤
MSJ-006 test PlatformOverheadPerformanceTest (3) same 구조적 회귀 예산 처리량 아님. System.gc() 의존
MSJ-007 assembly messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:358-366, 408-417 same 유일한 codec 조립 지점, varargs 비어 있음, payload 정책의 상수 출처 해당 leaf SSOT가 소유
EVD-272 command evidence/raw/272-schema-family-reachability.txt same codec별 소비자와 membership 정적 검색
EVD-275 command ./gradlew :messaging:messaging-schema-json:test --rerun-tasks same 18 / 0 / 0

A19-MESSAGING-SCHEMA-PROTOBUF. messaging-schema-protobuf

분석 중에는 messaging/MESSAGING-SCHEMA-PROTOBUF.md 파일이었다. 589줄.

messaging-schema-protobuf 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-schema-protobuf SSOT owner: messaging-schema-protobuf integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-schema-protobuf
  • canonical state analysisFile: §A19-MESSAGING-SCHEMA-PROTOBUF
  • source path: src/messaging/messaging-schema-protobuf
  • registry allowed_dependencies: ["messaging-core-api", "messaging-schema-api"]
  • registry runtime_memberships: [] — build-only / incubating
숫자
항목
production Java 파일 2
production LOC 199
패키지 1 (dev.caskeleton.messaging.schema.protobuf)
test 파일 1
test 메서드(실행 확인) 12
test 리소스 src/test/proto/order_created_v1.proto (컴파일되지 않음)
외부 의존성 1 (com.google.protobuf:protobuf-java:4.29.3, api)

두 타입: ProtobufMessageCodec(codec), ProtobufMessageContract(record — 클래스와 parser의 검증된 짝).

Coverage ledger
scope/file group count disposition reason
.../protobuf/ProtobufMessageCodec.java 1 FULL_READ 146줄 전문
.../protobuf/ProtobufMessageContract.java 1 FULL_READ 53줄 전문
src/test/java/** 1 FULL_READ 255줄 전문
src/test/proto/order_created_v1.proto 1 FULL_READ 23줄 전문. 어느 빌드도 컴파일하지 않음(§12.4)
build.gradle 1 FULL_READ 주석 포함 11줄
gradle.lockfile 1 FULL_READ protobuf 좌표 2건 확인
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

선택적 Protobuf codec. runtime_memberships: []이고 starter의 codec registry에도 등록되지 않는다 — build-only / incubating.

protobuf를 api로 선언한 이유가 build.gradle 주석에 있다.

// api: ProtobufMessageContract is a public record over com.google.protobuf.Message and
// Parser, and registering a contract is the first thing a consumer of this codec does.
api 'com.google.protobuf:protobuf-java:4.29.3'

src/messaging/CLAUDE.md:40-43의 vendor api 게이트를 통과한다 — ProtobufMessageContract(Class<? extends Message>, Parser<? extends Message>)가 public record이므로 소비자가 그 타입을 이름 부르지 않고는 계약을 등록할 수 없다.

이 leaf의 핵심 문제 인식은 클래스 javadoc이 한 문장으로 적는다.

// ProtobufMessageCodec.java:25-27
 * <p>Bound to a closed registry of generated parsers. Protobuf's own wire format will happily
 * decode almost any bytes into almost any message, so without the registry a type confusion is
 * silent  the consumer gets a populated object built from the wrong schema rather than an error.

messaging-schema-avro의 "does not fail — it produces plausible garbage"와 같은 성질이다. JSON은 틀린 스키마로 디코딩하면 대개 실패하고, Avro와 Protobuf는 실패하지 않는다. 그래서 두 leaf 모두 registry를 계약의 중심에 둔다.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), messaging-schema-api(api), protobuf-java:4.29.3(api).

나가는 것: 없다. 어떤 leaf의 allowed_dependencies에도 없고 starter 목록에도 없다.

런타임 배선: 없음. bean 없음(Spring 주석 0개).

lockfile이 확인하는 실제 해석:

com.google.protobuf:protobuf-java:4.29.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor

컴파일/런타임은 4.29.3, annotation processor 경로만 4.33.2다. §12.4에서 저장소 전체의 protobuf 버전 지형을 다룬다.


3. 패키지/컴포넌트 지도

ProtobufMessageContract  (record)
├── payloadType : Class<? extends Message>
├── parser      : Parser<? extends Message>
└── compact 생성자가 빈 입력을 파싱해 짝을 증명

ProtobufMessageCodec  (MessageCodec 구현)
├── encode(type, version, Message)   → requireFits + writeTo(sink)
├── decode(type, version, byte[], Class) → parser.parseFrom
├── requireRegistered(type, version) → 2단 에러
└── registeredVersions(type)         → 에러 메시지용 정렬 목록

4. 계약·불변식·상태 모델

4.1 ProtobufMessageContract: 생성 시점에 짝을 증명한다

이 leaf에서 가장 밀도 높은 결정이다.

// ProtobufMessageContract.java:10-20
 * <p>They used to live in two parallel maps. Nothing checked that the two agreed, so a registry
 * that paired {@code OrderCreated.class} with {@code OrderCancelled}'s parser was accepted at
 * construction and produced a {@code ClassCastException} at decode time  on a broker thread, for
 * one message type, in production. Worse, a type present in one map and absent from the other made
 * {@code parsers.get(type)} return null and the decode fail with a {@code NullPointerException}
 * rather than the registry error the operator needed to read.
 *
 * <p>Binding them in one value makes the mismatch impossible to express, and the constructor proves
 * the pairing by parsing empty input: the parser's default instance must be an instance of the
 * declared class.

증명 방법이 영리하다.

public ProtobufMessageContract {
  Message defaultInstance;
  try {
    defaultInstance = parser.parseFrom(new byte[0]);
  } catch (Exception failure) {
    throw new MessagingConfigurationException("PROTOBUF_CONTRACT_UNUSABLE", ..., failure);
  }
  if (!payloadType.isInstance(defaultInstance)) {
    throw new MessagingConfigurationException("PROTOBUF_CONTRACT_MISMATCH", ...);
  }
}

proto3에서 모든 필드가 wire상 optional이므로 빈 바이트는 항상 유효한 메시지다. 그것을 파싱하면 default instance가 나오고 그 클래스가 곧 parser의 산출 타입이다. 별도 리플렉션 없이 짝을 확인한다.

에러 메시지가 실패 지점을 명시한다 — "a mismatched pairing fails at decode time on a broker thread, not here". 즉 여기서 실패하는 것이 목적임을 메시지가 스스로 말한다.

두 코드가 다르다: PROTOBUF_CONTRACT_UNUSABLE(파싱 자체 실패)과 PROTOBUF_CONTRACT_MISMATCH(파싱은 되는데 타입이 다름). 카테고리는 둘 다 CONFIGURATION이다.

테스트가 이 성질을 붙든다 — aParserThatDoesNotProduceTheDeclaredClassIsRejectedAtConstruction, as("the mismatch used to surface as a ClassCastException on a broker thread").

4.2 인코딩: 크기를 미리 알 수 있다
BoundedByteSink sink = BoundedByteSink.of(maxBytes, "PAYLOAD_TOO_LARGE");
sink.requireFits(message.getSerializedSize());
try {
  message.writeTo(sink);
}

주석이 이유를 적는다.

// :77-79
// Protobuf knows its serialized size exactly before writing a byte, so the limit is checked
// against that estimate first and enforced again by the sink. `toByteArray` allocated the whole
// encoding before anything could object.

세 codec 중 유일하게 사전 거절이 가능한 포맷이다. BoundedByteSink.requireFits가 이 leaf를 위해 존재하고, schema-api의 javadoc이 그것을 명시한다 — "Protobuf knows its serialized size exactly, so the whole encode can be refused before the first byte is written."

그리고 사전 검사가 사후 경계를 대체하지 않는다 — writeTo(sink)가 여전히 sink를 통과하므로 이중 방어다. schema-api javadoc: "this is a cheaper refusal, not a replacement for the bound."

4.3 인코딩 타입 검사: 이중 조건
if (!(payload instanceof Message message) || !contract.payloadType().isInstance(payload)) {
  throw new MessageValidationException("PAYLOAD_TYPE_MISMATCH", ...);
}

Message인지와 등록된 클래스의 인스턴스인지를 함께 본다. 후자만으로 충분해 보이지만 전자가 writeTo를 부를 수 있음을 보장한다.

4.4 디코딩: 정확 일치와 상한
if (!contract.payloadType().equals(payloadType)) { throw ... PAYLOAD_TYPE_MISMATCH ... }
if (encoded.length > maxBytes) { throw ... PAYLOAD_TOO_LARGE ... }
return payloadType.cast(contract.parser().parseFrom(encoded));

JSON codec과 같은 비대칭이다 — encode는 isInstance(하위 타입 허용), decode는 equals(정확 일치).

4.5 requireRegistered: 2단 에러, JSON과 같은 어휘
// :123-127
if (typeIsKnown) {
  // Protobuf will happily decode almost any bytes with almost any parser, so falling back to
  // another version's parser does not fail — it returns a populated object built from a schema
  // nobody registered for this version.
  throw new MessageValidationException("SCHEMA_VERSION_NOT_REGISTERED", ...);
}
throw new MessageValidationException("UNKNOWN_MESSAGE_TYPE", ...);

코드 문자열이 JacksonMessageCodec과 동일하다(SCHEMA_VERSION_NOT_REGISTERED, UNKNOWN_MESSAGE_TYPE). AvroMessageCodecAVRO_ 접두사를 붙여 어휘가 갈라진다 — §A19-MESSAGING-SCHEMA-AVRO §12.3(d)가 소유한다.

에러 메시지에 registeredVersions(type)가 정렬되어 포함되는 것도 JSON과 같다.

4.6 unknown field 보존
// :29-31
 * <p>Unknown fields are preserved by the generated types, which is what makes forward compatibility
 * work: an old consumer round-tripping a message written by a newer producer does not silently drop
 * the fields it does not understand.

이것은 이 codec이 하는 일이 아니라 protobuf-java 생성 타입의 성질이다. 테스트가 그 성질을 직접 확인한다 — aNewWriterIsStillReadableByAnOldReaderasV1.getUnknownFields().hasField(5)를 단언하고 as("the unrecognised field is retained, not dropped, so a round trip does not lose it")라고 적는다.


5. 주요 실행 경로

계약 등록: new ProtobufMessageContract(class, parser) → 빈 입력 파싱 → 클래스 일치 확인 → 실패 시 MessagingConfigurationException

encode: requireRegisteredMessage이고 등록 클래스인지 → requireFits(getSerializedSize())writeTo(sink)EncodedMessage(bytes, PROTOBUF, SchemaReference)

decode: requireRegistered → 요청 클래스 정확 일치 → encoded.length 상한 → parser.parseFrom


6. 실패 경로와 복구/번역

코드 예외 카테고리 조건
PROTOBUF_CONTRACT_UNUSABLE MessagingConfigurationException CONFIGURATION parser가 빈 입력을 파싱하지 못함
PROTOBUF_CONTRACT_MISMATCH MessagingConfigurationException CONFIGURATION parser 산출 클래스 ≠ 선언 클래스
UNKNOWN_MESSAGE_TYPE MessageValidationException PERMANENT_BUSINESS 타입 미등록
SCHEMA_VERSION_NOT_REGISTERED MessageValidationException PERMANENT_BUSINESS 버전 미등록
PAYLOAD_TYPE_MISMATCH MessageValidationException PERMANENT_BUSINESS 타입 불일치(양방향)
PAYLOAD_TOO_LARGE MessageTooLargeException PERMANENT_BUSINESS 크기 초과
PROTOBUF_ENCODE_FAILED MessageSerializationException DESERIALIZATION IOException
PROTOBUF_DECODE_FAILED MessageSerializationException DESERIALIZATION InvalidProtocolBufferException

Avro와 다른 점 하나. Avro는 catch (IOException | RuntimeException) 안에서 MessageTooLargeExceptioninstanceof로 통과시킨다. Protobuf는 catch (IOException failure)만 잡으므로 sink가 던지는 MessageTooLargeException(RuntimeException)이 그대로 전파된다. 별도 통과 로직이 필요 없다 — protobuf-java가 예외를 감싸지 않기 때문이다. 세 codec이 같은 문제를 세 가지로 푸는데(JSON은 원인 사슬 탐색, Avro는 즉시 instanceof, Protobuf는 아무것도 안 함) 각각 라이브러리 동작에 맞는 최소 해법이다. 다만 그 이유가 코드에 적혀 있지 않다.

계약 위반은 CONFIGURATION이고 메시지 실패가 아니다. ProtobufMessageContract 생성 실패는 registry를 조립하는 시점, 즉 시작 시점에 난다. MessagingConfigurationException javadoc이 그 의도를 적는다 — "Raised at startup wherever possible."


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

ProtobufMessageCodec은 불변이다 — contractsMap.copyOf, maxBytes는 int. ProtobufMessageContract는 record이고 Class/Parser 둘 다 protobuf-java에서 스레드 안전하다.

BoundedByteSink는 매 encode마다 새로 만들어진다.

Map.copyOf가 여기서는 얕은 복사 문제가 없다Map<MessageContractKey, ProtobufMessageContract>가 이미 평탄한 한 레벨이다. AvroMessageCodec이 중첩 맵을 받아 flatten이 필요했던 것과 대비된다(§messaging-schema-avro §4.2). 두 codec이 같은 registry 개념을 다른 형태로 받았고, 평탄한 쪽이 결함을 만들지 않았다.


8. 설정·기능 플래그·환경 차이

설정 없음.

상수 가시성
ProtobufMessageCodec.DEFAULT_MAX_BYTES 1,048,576 private

protobuf-java 버전은 4.29.3으로 build.gradle에 직접 고정돼 있다 — §12.4.


9. 퍼시스턴스/외부 시스템 세부

없다. 외부 schema registry를 쓰지 않는다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-schema-protobuf:test. BUILD SUCCESSFUL, 12 tests, 0 skipped, 0 failures.

테스트 하나가 모든 것을 덮는다: ProtobufCompatibilityTest.

테스트 증명하는 것
aRoundTripPreservesEveryField 인코딩/디코딩 왕복, content type
renamingAFieldKeepsItsValueBecauseTheTagNumberIsTheContract 태그 4의 이름을 currencycurrency_code로 바꿔도 값 보존
anAddedFieldDecodesAsItsDefaultForAnOldWriter v1이 쓴 바이트를 v2로 읽으면 새 필드가 기본값 ""
aNewWriterIsStillReadableByAnOldReader v2가 쓴 것을 v1로 읽어도 태그 4 보존, 태그 5는 unknown field로 유지
reusingATagNumberCorruptsTheReadWhichIsWhyTagsAreNeverRecycled 태그 4를 string→int64로 재사용하면 값이 0L로 소실
anUnregisteredTypeIsRejectedRatherThanGuessed 타입 미등록 거절
anUnregisteredVersionIsRejectedRatherThanDecodedWithAnotherVersionsParser v2 요청이 v1 parser로 폴백하지 않음, 메시지에 order.created v2 포함
aParserThatDoesNotProduceTheDeclaredClassIsRejectedAtConstruction 짝 검증
anOversizedPayloadIsRefusedBeforeItIsSerialized 16바이트 상한에서 refused at byte
aPayloadAtExactlyTheLimitIsAccepted 정확히 상한인 payload 허용
aLengthPrefixNoPayloadOfThisSizeCouldHonourIsADecodeFailure 4억 바이트를 주장하는 6바이트 메시지가 할당이 아니라 디코딩 실패로 끝남
theEncodedMessageCarriesItsSchemaReference schema reference의 버전

테스트 설계의 핵심 결정이 클래스 javadoc에 있다.

// ProtobufCompatibilityTest.java:30-33
 * <p>Descriptors are built at runtime rather than generated by protoc. The properties under test 
 * that a reader keyed on tag numbers survives a rename, that an added field decodes as its default,
 * and that reusing a tag corrupts the read  are properties of the wire format, so proving them
 * without a code-generation step keeps the test honest and the build free of a protoc toolchain.

DescriptorProto/FileDescriptor/DynamicMessage로 런타임에 스키마를 만든다. 그래서 이 leaf의 빌드에 protoc 툴체인이 없다.

aLengthPrefixNoPayloadOfThisSizeCouldHonourIsADecodeFailure가 Avro와의 대비를 만든다. 같은 형태의 공격(작은 바이트로 큰 길이를 주장)이 Avro에서는 newArray 오버라이드가 필요했고 Protobuf에서는 라이브러리가 알아서 막는다.

// 테스트 주석 :238-240
// Tag 1, wire type 2 (length-delimited), then a varint claiming four hundred million bytes
// follow. The whole message is six bytes, so it passes the size limit; what must not happen is
// the parser reserving the claimed length before discovering there is nothing behind it.

결과가 MessageSerializationException이다 — 즉 protobuf-java는 길이 주장을 신뢰해 미리 할당하지 않는다. Avro의 GenericDatumReader.newArray는 신뢰한다. 같은 공격에 두 라이브러리의 기본 방어가 다르고, 이 저장소는 그 차이를 각 leaf에서 다르게 처리했다.

증명 공백. ProtobufMessageCodec.decode의 상한 검사(encoded.length > maxBytes)를 직접 겨냥한 테스트가 없다. 인코딩 상한은 두 테스트가 덮지만 디코딩 상한은 덮이지 않는다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api","messaging-schema-api"]
verifyRuntimeModuleMembership []
vendor api 규칙(src/messaging/CLAUDE.md:40-43) protobuf가 public record 시그니처에 등장 → api 필요. 통과
Gradle dependency locking gradle.lockfile이 4.29.3/4.33.2를 고정
ArchUnit 전용 규칙 없음
protoc 툴체인 없음 — 의도적(§10)

12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/272-schema-family-reachability.txt.

12.1 Public surface reachability
타입 leaf 밖 참조 판정
ProtobufMessageCodec 0 소비자 없음
ProtobufMessageContract 0 소비자 없음

git grep -l -w ProtobufMessageCodec -- src ':!src/messaging/messaging-schema-protobuf' exit 1.

정합적이다. runtime_memberships: [], starter 미등록, 소비자 0 — 세 축이 모두 "없음"이다. messaging-schema-avro와 같은 형태이고, 이것이 incubating leaf의 올바른 상태다.

한계. 이 저장소는 템플릿이므로 파생 프로젝트가 이 codec을 쓸 수 있다. 그것을 확인할 수단이 저장소 안에 없다. 다만 이 leaf는 그 경우를 위해 준비돼 있다 — vendor를 api로 노출했고, 계약 등록이 첫 단계임을 build.gradle 주석이 명시한다.

12.2 Conditional sibling comparison

Spring 주석 0개. bean 없음.

codec sibling 비교는 §A19-MESSAGING-SCHEMA-AVRO §12.2의 표가 소유한다. 이 leaf는 Avro와 같은 행(구현 o / starter 등록 x / membership [] / 정합)이다.

12.3 Duplicate mechanism sweep

(a) registry 조회 로직이 세 codec에 복제돼 있다

requireRegistered(JSON), schemaFor(Avro), requireRegistered(Protobuf)가 같은 구조다.

key = (type, version)
if 등록됨 → 반환
typeIsKnown = 키들 중 type이 같은 것이 있는가
if typeIsKnown → "버전 미등록" + 등록 버전 목록
else           → "타입 미등록"

JSON과 Protobuf는 registeredVersions(type) 헬퍼까지 사실상 동일하다(스트림 필터 → 버전 추출 → 정렬 → 리스트). Avro는 등록 버전 목록을 메시지에 넣지 않는다.

이 중복은 messaging-schema-api가 흡수할 수 있었다 — MessageContractKey가 이미 그 leaf에 있고, "타입은 알고 버전을 모른다"는 판단은 키의 성질이지 포맷의 성질이 아니다. SchemaCompatibilityValidator가 진화 규칙에 대해 정확히 그 일을 하려 했던 것과 같은 구조이고, 그쪽은 호출되지 않았다(§A19-MESSAGING-SCHEMA-API §12.1).

(b) 크기 예외 통과 방식이 세 codec에 셋

codec 방식 필요한 이유
JSON unwrapTooLarge 원인 사슬 탐색 Jackson이 스트림 예외를 감쌈
Avro catch 안 즉시 instanceof(3곳) Avro가 감싸지 않지만 IOException과 함께 잡힘
Protobuf 없음 catch (IOException)만 잡으므로 그대로 전파

셋 다 라이브러리 동작에 맞는 최소 해법이고 결과는 같다. 중복 경쟁이 아니라 불가피한 분기로 분류한다. 다만 세 코드 어디에도 "왜 우리는 다른가"가 적혀 있지 않아, 넷째 codec을 추가하는 사람이 어느 형태를 골라야 하는지 알 수 없다.

(c) 1 MiB 상한 — §A19-MESSAGING-SCHEMA-JSON §12.3이 소유한다. 이 leaf의 DEFAULT_MAX_BYTES는 private이므로 외부에 값을 노출하지 않는다.

12.4 Documentation / measured-count drift

(a) .proto fixture를 컴파일하는 빌드가 없다

src/test/proto/order_created_v1.proto가 존재하고 v1 계약을 서술한다.

message OrderCreated {
  string order_id = 1;
  string customer_id = 2;
  int64 total_minor_units = 3;
  string currency = 4;
  // v2 adds `channel = 5`. ...
}

테스트는 이것을 읽지 않는다. DescriptorProto로 손수 만든 V1_DESCRIPTOR가 같은 네 필드를 같은 태그로 선언하고, V2_DESCRIPTOR가 태그 4를 currency_code로 개명하고 태그 5 channel을 추가한다.

오늘은 둘이 일치한다. 필드 이름·태그·타입을 전수 대조했고 .proto의 주석이 예고하는 v2 변경도 테스트의 V2_DESCRIPTOR와 맞는다. 그러나 일치를 강제하는 것이 아무것도 없다 — protoc 툴체인이 없고, 테스트가 파일을 읽지 않으며, 게이트도 없다. 테스트 javadoc이 .proto를 "the fixture documents"라고 부르는데, 문서와 테스트가 각자 진실을 갖고 있다.

이 판단은 신중해야 한다. protoc를 뺀 것은 명시적 설계 결정이고 그 이유(테스트를 정직하게, 빌드를 가볍게)가 적혀 있다. 문제는 protoc의 부재가 아니라 .proto가 남아 있으면서 아무도 검증하지 않는다는 것이다.

(b) protobuf-java 버전이 저장소에 셋 있다

위치 버전 성격
src/build.gradle:180 ext.protobufVersion 3.25.5 주석이 "the single SSOT"라 부름
messaging-schema-protobuf/build.gradle:9 4.29.3 이 leaf가 직접 고정
adapter/inbound/websocket/build.gradle:44,46 4.33.2 compileOnly / testImplementation
다수 lockfile의 annotationProcessor 경로 4.33.2 전이

src/build.gradle:174-180의 주석을 정확히 읽어야 한다.

// Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or
// protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the
// grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as
// platforms at MODULE scope (not the shared dependencyManagement block below) — keeping the
// strict-locking blast radius to the grpc module alone.

"single SSOT"의 범위가 문장 안에서 grpc 모듈로 한정된다 — "keeping the strict-locking blast radius to the grpc module alone". 따라서 이 leaf가 4.29.3을 쓰는 것은 그 SSOT를 위반한 것이 아니다. 정확한 사실은 이렇다: 저장소에 protobuf 버전 정책이 전역으로 존재하지 않고, 세 곳이 독립적으로 고정한다. 그리고 "single SSOT"라는 표현이 전역 정책의 존재를 시사하는 반면 실제 범위는 한 모듈이다.

오늘 이것이 사고가 아닌 이유: 이 leaf의 runtime_memberships[]이므로 4.29.3이 4.33.2·3.25.5와 같은 classpath에 오르지 않는다. 채택 시점의 부채이지 지금의 결함이 아니다. 이 leaf를 런타임에 편입시키면 그때 버전 충돌 판정이 필요해진다.

(c) 일치하는 주장들

문서 주장 재측정 결과
build.gradle 주석: protobuf가 public 시그니처에 등장하므로 api ProtobufMessageContract가 public record over Message/Parser 일치
클래스 javadoc: unknown field가 보존됨 테스트가 getUnknownFields().hasField(5) 확인 일치
support-matrix.md: Protobuf가 Stable 아님 membership [], starter 미등록 일치
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 실제로 [] 이 leaf에 한해 참(family 전체로는 틀림)

13. Git/설계 문서에서 확인한 변화와 실패 기록

ProtobufMessageContract javadoc이 두 결함을 보존한다.

이전 상태 그것이 만든 실패
클래스와 parser를 두 개의 병렬 맵에 보관, 일치 검사 없음 OrderCreated.classOrderCancelled의 parser 짝이 생성 시 통과 → 디코딩 시점의 ClassCastException, 브로커 스레드에서, 한 메시지 타입에 대해, production에서
한쪽 맵에만 존재하는 타입 parsers.get(type)이 null → NullPointerException. 운영자가 읽어야 할 registry 에러 대신 NPE

두 번째가 특히 이 저장소의 반복 주제다 — 실패의 종류가 바뀌면 운영자가 읽을 정보가 사라진다. messaging-core-apiFailureDescriptor 설계, MessageContractKey의 2단 에러, JSON codec의 unwrapTooLarge가 전부 같은 관심사다.

.proto 파일의 주석도 설계 이유를 남긴다 — "Field numbers are the contract, not the field names ... Tags are never reused, and removed fields are reserved so that a later edit cannot take the number back." 이 규칙 셋 중 둘(개명 안전, 태그 재사용 위험)이 테스트로 증명되고 하나(reserved)는 증명되지 않는다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-272 command evidence/raw/272-schema-family-reachability.txt §D, §E 두 타입의 소비자 0, membership [] 정적 검색
EVD-277 command ./gradlew :messaging:messaging-schema-protobuf:test --rerun-tasks BUILD SUCCESSFUL, 12 / 0 / 0 protoc 없음. 런타임 descriptor

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 닫힌 registry가 없으면 타입 혼동이 조용하다 — 클래스 javadoc
  • 두 병렬 맵이 만든 두 결함과 짝 증명 방식 — ProtobufMessageContract javadoc
  • 크기를 미리 알 수 있어 사전 거절한다 — encode 주석
  • 다른 버전 parser로 폴백하지 않는 이유 — requireRegistered 주석
  • unknown field 보존이 forward compatibility의 기반 — 클래스 javadoc
  • descriptor를 런타임에 만드는 이유(protoc 툴체인 회피) — 테스트 javadoc
  • 태그 번호가 계약인 이유 — .proto 주석
  • protobuf를 api로 선언한 이유 — build.gradle 주석
  • ext.protobufVersion의 범위가 grpc 모듈로 한정된 이유 — src/build.gradle:174-178

추론

  • 크기 예외 통과 로직이 없는 것은 protobuf-java가 예외를 감싸지 않기 때문이다 → 추론. 코드 형태는 관측, 인과는 추론.
  • 4.29.3을 고른 이유 → 미상. 주석도 커밋 메시지도 없다.
  • .proto를 남겨 둔 이유 → 미상. 문서용으로 보이지만 명시되지 않았다.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 두 타입 199줄 전문의 계약
  • 12개 테스트가 통과하고 무엇을 단언하는지
  • 소비자 0 / starter 미등록 / membership []의 삼중 정합
  • .proto fixture와 테스트 descriptor가 오늘 일치한다는 것(전수 대조)과 그것을 강제하는 것이 없다는 것
  • 저장소에 protobuf 버전이 셋 있고 "single SSOT"의 범위가 한 모듈이라는 것
  • 길이 주장 공격에 대해 protobuf-java가 Avro와 달리 사전 할당하지 않는다는 것(테스트로 확인)

확인하지 못한 것

  • 디코딩 상한을 겨냥한 테스트가 없다. encoded.length > maxBytes 분기가 실행된 적이 없다.
  • .proto 주석이 말하는 reserved 규칙 — 테스트가 없다.
  • 파생 프로젝트가 이 codec을 쓰는지.
  • 4.29.3과 3.25.5·4.33.2가 한 classpath에 올랐을 때 무슨 일이 생기는지. 오늘은 그 조합이 존재하지 않는다.
  • 실제 protoc 생성 타입(GeneratedMessage 서브클래스)에서 ProtobufMessageContract의 빈 입력 파싱 증명이 동작하는지 — 테스트는 DynamicMessage만 쓴다.

17. 손볼 것

P3 — .proto fixture와 테스트 descriptor의 일치를 아무도 강제하지 않는다
  • 사실. src/test/proto/order_created_v1.proto가 v1 계약을 서술하고, 테스트는 그 파일을 읽지 않고 DescriptorProto로 같은 스키마를 손수 만든다. 오늘 둘은 일치한다(필드 4개, 태그 1–4, 타입 전수 대조).
  • 근거. .proto 전문 vs ProtobufCompatibilityTest.java:41-66.
  • 왜 문제인가. protoc를 뺀 것은 명시적 설계 결정이고 이유가 적혀 있다. 문제는 .proto가 남아 있으면서 검증되지 않는다는 것이다. 테스트 javadoc이 그것을 "the fixture documents"라 부르므로, 읽는 사람은 그 파일이 테스트의 근거라고 믿는다. 한쪽만 수정되면 조용히 갈라진다.
  • 확인 방법. 두 파일의 필드/태그/타입 대조. find src/messaging/messaging-schema-protobuf -name '*.proto'
  • 후보. (a) .proto를 읽어 descriptor를 만드는 테스트 헬퍼를 쓴다(protoc 없이 protobuf-java의 파서로는 불가하므로 실제로는 어렵다). (b) .proto를 삭제하고 규칙 주석을 테스트로 옮긴다. (c) .proto에 "이 파일은 문서이며 테스트는 descriptor를 손수 만든다"를 명시한다.
  • 다음 단계. REFERENCE 후보(검증되지 않는 스키마 파일은 문서임을 파일 안에 적는다).
P3 — 디코딩 상한 분기가 테스트되지 않는다
  • 사실. decodeif (encoded.length > maxBytes) 분기를 겨냥한 테스트가 없다. 인코딩 상한은 두 테스트가 덮는다.
  • 근거. ProtobufMessageCodec.java:104-108, ProtobufCompatibilityTest 12개 전수.
  • 왜 문제인가. 디코딩은 신뢰할 수 없는 입력을 받는 쪽이다. 브로커에서 온 바이트에 대한 방어가 자기 코드가 만든 바이트에 대한 방어보다 덜 검증됐다. 형제 leaf는 반대다 — AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound가 정확히 이 각도를 덮는다.
  • 확인 방법. 12개 테스트 중 decode에 큰 입력을 주는 것이 없음.
  • 후보. maxBytes보다 큰 byte[]decode를 부르는 테스트 추가.
  • 다음 단계. REFERENCE 후보(신뢰할 수 없는 입력 쪽 경계를 먼저 테스트한다).
P3 — protobuf-java 버전이 저장소에 셋이고 전역 정책이 없다
  • 사실. ext.protobufVersion = 3.25.5(grpc 모듈 범위로 한정), 이 leaf 4.29.3, websocket 4.33.2. lockfile들이 세 값을 모두 고정한다.
  • 근거. src/build.gradle:174-180 · messaging-schema-protobuf/build.gradle:9 · adapter/inbound/websocket/build.gradle:44,46 · 각 gradle.lockfile.
  • 왜 문제인가. 오늘은 사고가 아니다 — 이 leaf의 runtime_memberships[]이라 세 버전이 한 classpath를 공유하지 않는다. 채택 시점의 부채다. 이 leaf를 런타임에 편입시키는 순간 버전 판정이 필요해지고, 그때 참조할 전역 정책이 없다. 그리고 src/build.gradle의 "the single SSOT"라는 표현이 전역 정책의 존재를 시사하는데 실제 범위는 그 문장 안에서 grpc 모듈로 한정된다.
  • 확인 방법. git grep -n 'protobuf-java\|protobufVersion' -- src --include='*.gradle'
  • 후보. (a) 편입 전까지 현 상태 유지하되 src/messaging/CLAUDE.md에 "편입 시 버전 정합을 먼저 판정한다"를 적는다. (b) ext.protobufVersion의 범위를 넓히고 주석의 "single SSOT" 표현을 실제 범위에 맞춘다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 "이 leaf를 런타임에 편입할 것인가"에 걸린다. 저장소 안에 답이 없다.
P3 — registry 조회 로직이 세 codec에 복제돼 있다
  • 사실. requireRegistered(JSON/Protobuf)와 schemaFor(Avro)가 같은 3단 판단을 각자 구현한다. JSON과 Protobuf는 registeredVersions 헬퍼까지 사실상 동일하다.
  • 근거. 세 codec의 해당 메서드.
  • 왜 문제인가. 판단은 MessageContractKey의 성질이지 포맷의 성질이 아니다. 그리고 실제로 갈라졌다 — Avro만 AVRO_ 접두 코드를 쓰고 등록 버전 목록을 메시지에 넣지 않는다. messaging-schema-api가 흡수할 수 있는 형태다.
  • 확인 방법. 세 메서드 대조.
  • 후보. messaging-schema-apiContractLookup류 헬퍼를 두고 세 codec이 부른다.
  • 다음 단계. messaging-schema-api §17의 "포맷 독립 규칙" 항목과 같은 계열이다. 그 leaf가 소유하고 여기서는 교차 참조만 남긴다.
확인된 설계(문제 아님)
  • 클래스와 parser를 한 값에 묶고 빈 입력 파싱으로 짝을 증명하는 것
  • 직렬화 크기를 미리 알아 사전 거절하고, sink 경계를 여전히 통과시키는 이중 방어
  • 다른 버전 parser로 폴백하지 않고 등록 버전 목록을 에러에 넣는 것
  • descriptor를 런타임에 만들어 protoc 툴체인 없이 wire 성질을 증명하는 것
  • 소비자 0 / starter 미등록 / membership []의 삼중 정합
  • 크기 예외 통과 로직이 없는 것(protobuf-java가 감싸지 않으므로 불필요)

Source anchors

id kind path revision what it proves limitations
MSP-001 registry src/config/architecture/modules.json 21234e38 deps, runtime_memberships: [] 선언
MSP-002 build messaging-schema-protobuf/build.gradle same protobuf api 선언과 이유, 버전 4.29.3
MSP-003 build messaging-schema-protobuf/gradle.lockfile:26-27 same 4.29.3(compile/runtime), 4.33.2(annotationProcessor) 이 leaf 범위
MSP-004 code .../protobuf/ProtobufMessageContract.java 전문 same §4.1 짝 증명과 두 이전 결함 DynamicMessage로만 검증됨
MSP-005 code .../protobuf/ProtobufMessageCodec.java 전문 same §4.24.6
MSP-006 test ProtobufCompatibilityTest (12) same §10 표 전부 protoc 없음. decode 상한 미검증
MSP-007 fixture src/test/proto/order_created_v1.proto same 태그 규칙 서술 컴파일되지 않음(§12.4a)
MSP-008 build policy src/build.gradle:174-180 same ext.protobufVersion = 3.25.5와 그 범위가 grpc 모듈로 한정됨
MSP-009 cross-leaf build adapter/inbound/websocket/build.gradle:44,46 same 세 번째 protobuf 버전 4.33.2 해당 leaf SSOT가 소유
MSP-010 cross-leaf code messaging-schema-api/.../BoundedByteSink.java:66-80 same requireFits가 이 codec을 위해 존재 해당 leaf SSOT가 소유
EVD-272 command evidence/raw/272-schema-family-reachability.txt same §12.1 정적 검색
EVD-277 command ./gradlew :messaging:messaging-schema-protobuf:test --rerun-tasks same 12 / 0 / 0

A19-MESSAGING-SECURITY. messaging-security

분석 중에는 messaging/MESSAGING-SECURITY.md 파일이었다. 741줄.

messaging-security 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-security SSOT owner: messaging-security integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-security
  • canonical state analysisFile: §A19-MESSAGING-SECURITY
  • source path: src/messaging/messaging-security
  • registry allowed_dependencies: ["messaging-core-api"]
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 12
production LOC 954
패키지 1 (dev.caskeleton.messaging.security)
test 파일 3
test 메서드(실행 확인) 24
외부(비프로젝트) 의존성 0

12개 타입을 세 축으로:

타입 leaf 밖 소비 파일
자격증명 수명주기 CredentialProvider · CredentialRuntime · CredentialRuntimeRegistry · CredentialRotationPlan · CredentialIds(package-private) 4 · 2 · 6 · 0 · 0
연결 posture BrokerSecurityProfile · BrokerCredentialProfile · BrokerTlsPolicy · MessageSecurityValidator 8 · 5 · 6 · 1
권한 DestinationAccessPolicy · DestinationAccessValidator · BrokerAclManifest 7 · 0 · 0
Coverage ledger
scope/file group count disposition reason
src/main/java/** (12) 12 FULL_READ 전 파일 본문 확인
src/test/java/** (3) 3 FULL_READ 테스트명·단언 전수 확인
build.gradle 1 FULL_READ 5줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

이 leaf는 **"브로커에 연결하기 전에 무엇이 참이어야 하는가"**를 소유한다. 벤더 의존성이 0이고 브로커를 만지지 않는다 — 어댑터의 security configurer가 이 leaf의 타입을 받아 실제 클라이언트 설정을 만든다.

세 가지 원칙이 코드 전반에 반복된다.

(a) 비밀은 참조로만 다룬다.

// BrokerCredentialProfile.java:5-8
 * <p>No variant carries a secret. The platform stores an identifier and resolves the material
 * through a {@link CredentialProvider} at connect time, so a rotation is a provider concern and a
 * heap dump or configuration print never yields a usable credential.

BrokerCredentialProfile의 다섯 변형 전부가 credentialId 하나만 갖는다 — SaslScram, OAuth2, MutualTls, UsernamePassword, Nkey. sealed interface이므로 여섯 번째를 만들려면 이 파일을 고쳐야 한다.

(b) 타입이 통제의 일부다.

// CredentialRuntime.java:13-15
 * <p>Holds the material in a {@code char[]} that {@link #clear()} overwrites. A {@code String}
 * cannot be erased  it stays in the constant pool and in every heap dump taken until the next GC
 * decides otherwise  so the type of the field is itself part of the control.

CredentialProvider.resolvechar[]을 반환하고 CredentialRuntime이 그것을 참조로 보관하며 clear()Arrays.fill(material, '\0') 후 빈 배열로 교체한다.

(c) 역할 분리가 강제된다. BrokerSecurityProfile이 producer·consumer·admin 세 자격증명을 별도 필드로 갖는다.

// BrokerSecurityProfile.java:9-11
 * <p>Producer, consumer, and admin credentials are separate fields rather than one connection
 * credential. That separation is what makes "an application cannot purge a topic" enforceable: the
 * runtime never holds admin material, so a compromised handler has nothing to escalate with.

2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api) 하나.

나가는 것: messaging-runtime-core, messaging-kafka, messaging-rabbit, messaging-admin-runtime, messaging-pulsar-experimental, messaging-nats-experimental, messaging-spring-boot-starter.

이 leaf는 messaging family에서 배선이 가장 잘 된 축에 속한다. 어댑터 두 곳이 직접 소비한다.

소비자 무엇을 쓰는가
messaging-kafka/KafkaSecurityConfigurer BrokerTlsPolicy, CredentialRuntimeRegistry, CredentialProvider
messaging-rabbit/RabbitSecurityConfigurer BrokerTlsPolicy, CredentialRuntimeRegistry
messaging-runtime-core/DefaultMessagePublisher DestinationAccessPolicy
messaging-runtime-core/DeclaredDestinationAccess DestinationAccessPolicy
starter MessagingCoreAutoConfiguration MessageSecurityValidator·BrokerTlsPolicy·CredentialRuntimeRegistry bean
starter MessagingCredentialRequirementValidator CredentialProvider
starter Kafka/RabbitMessagingAutoConfiguration BrokerTlsPolicy, CredentialRuntimeRegistry

이 leaf 자체는 Spring 주석을 갖지 않는다.


3. 패키지/컴포넌트 지도

자격증명 수명주기
  CredentialProvider (port)
        ↓ resolve(id) → char[] / expiresAt(id) → Optional<Instant>
  CredentialRuntimeRegistry ──compute(single-flight)──> CredentialRuntime
        │                                                  ├── material() → clone
        │                                                  ├── isDueForRotation(now)
        │                                                  ├── isExpired(now)
        └── dueForRotation / expired / clearAll             └── clear() → 덮어쓰기

  CredentialRotationPlan   ← 같은 술어를 다시 구현, 소비자 0 (§12.3)

연결 posture
  BrokerSecurityProfile ─┬─ BrokerCredentialProfile (sealed, 5변형) ── CredentialIds
                         └─ DestinationAccessPolicy
  BrokerTlsPolicy.validate(profile, protocols)   ← 어댑터가 호출
  MessageSecurityValidator.validate(profile)     ← starter bean, 검사 범위가 겹침 (§12.3)

권한
  DestinationAccessPolicy (publishable / consumable / administrable)
  DestinationAccessValidator  ← 소비자 0 (§12.1)
  BrokerAclManifest           ← 소비자 0 (§12.1)

4. 계약·불변식·상태 모델

4.1 CredentialRuntimeRegistry.resolve — key별 single-flight

이 leaf에서 가장 조밀한 동시성 코드이고, 이전 결함이 주석에 통째로 남아 있다.

// :62-70
// Single-flight, keyed by credential id.
//
// get → fetch → put → clear had no synchronization at all. Two callers rotating the same
// credential both read the same old runtime and both fetched a replacement: one replacement
// was lost from the map without ever being cleared — a secret left in memory that nothing owns
// — and the caller that lost the race could clear material the winner was still using.
//
// compute holds the bin lock for this key, so exactly one fetch publishes and the previous
// generation is retired by that same caller.

두 개의 서로 다른 결함이 한 경합에서 나왔다.

  1. 진 쪽의 교체본이 맵에서 사라지고 clear()도 안 됨 → 소유자 없는 비밀이 힙에 남음
  2. 진 쪽이 이긴 쪽이 쓰고 있는 material을 clear()할 수 있음 → 사용 중인 자격증명이 지워짐

현재 구현:

CredentialRuntime current = resolved.get(credentialId);
if (current != null && !current.isDueForRotation(now)) {
  return current;                                   // 락 없는 빠른 경로
}
return resolved.compute(credentialId, (key, existing) -> {
  if (existing != null && !existing.isDueForRotation(now)) {
    return existing;                                // 대기 중 다른 스레드가 회전함
  }
  CredentialRuntime replacement = fetch(key, now);
  if (existing != null) {
    existing.clear();                               // 설치 후에만, 그리고 교체한 스레드만
  }
  return replacement;
});

ConcurrentHashMap.compute가 해당 bin의 락을 잡으므로 fetch가 정확히 한 번 일어난다. 그리고 clear()replacement 생성 후에 온다 — 주석이 그 순서의 이유를 적는다: "no reader sees a window with no usable credential — and only by the thread that replaced it, so the material a concurrent reader holds is never wiped underneath it."

대가. compute의 람다 안에서 provider.resolve(...)가 호출된다. 즉 외부 I/O가 맵 bin 락을 잡은 채로 일어난다. 같은 credential id를 요청하는 다른 스레드는 그 동안 막히고, ConcurrentHashMap 문서는 compute 람다 안에서 같은 맵을 갱신하지 말라고 요구한다(여기서는 지켜진다). 다른 키는 다른 bin이면 막히지 않지만 해시 충돌 시 같은 bin이면 막힌다. §17.

4.2 CredentialRuntime — material의 세 가지 통제
통제 구현
저장 char[], String 아님
반환 material.clone() — "A copy, so a caller that clears its own array cannot blind every other holder"
소거 Arrays.fill(material, '\0')material = new char[0]
소거 후 접근 IllegalStateException("credential X has already been cleared")
표현 toString()이 id와 expiry만 — material 없음

소거 판정이 material.length == 0이다. 생성자가 빈 배열을 거절하므로("credential material must not be empty") 길이 0은 소거된 상태를 뜻한다 — 별도 플래그 없이 같은 필드로 상태를 표현한다.

material 필드가 volatile이 아니다. clear()가 다른 스레드에서 호출되면 material()이 옛 참조를 볼 수 있다. 실제 경로에서는 compute 안에서만 clear()가 불리고 그 전에 replacement가 맵에 들어가므로 위험이 낮지만, clearAll()은 락 없이 순회한다. §17.

4.3 회전 시점 — 만료가 아니라 만료 이전
// CredentialRuntime.java:17-18
 * <p>Rotation is driven from the expiry, ahead of it. Waiting for the broker to start refusing
 * connections turns a scheduled, invisible rotation into an outage.

DEFAULT_ROTATION_LEAD = 30분. isDueForRotation(now)!now.isBefore(expiry.minus(rotationLead))다 — 만료 30분 전부터 참이고 만료 후에도 참이다.

expiresAt이 비어 있으면 둘 다 false다 — 만료를 모르는 자격증명은 회전 대상도 만료 대상도 아니다. orElse(false)가 그 선택을 명시한다.

4.4 BrokerTlsPolicy — 허용목록과 두 단계 실패
// :15-17
 * <p>Disabling hostname verification is treated as a separate, worse failure than disabling TLS.
 * Plaintext is at least obviously insecure, whereas TLS without hostname verification looks
 * encrypted in every dashboard while accepting any certificate a man in the middle presents.

네 가지 거절:

코드 조건
TLS_REQUIRED TLS 꺼짐 && (production || 평문 비허용)
HOSTNAME_VERIFICATION_REQUIRED TLS 켜짐 && hostname 검증 꺼짐
TLS_PROTOCOL_NOT_ACCEPTED 프로토콜이 {TLSv1.2, TLSv1.3}
TLS_PROTOCOL_UNSPECIFIED TLS 켜짐인데 프로토콜 목록이 비어 있음

허용목록을 고른 이유가 적혀 있다.

// :72-78
// An allowlist, not a denylist.
//
// The denylist named the old versions somebody thought of, so `SSL`, `TLSv0.9`, `PLAINTEXT`
// and any typo passed — and a protocol string the JVM does not recognise is negotiated as
// whatever the JVM defaults to, which is the outcome this policy exists to prevent. Naming the
// two acceptable versions means an unknown string fails here rather than at connect time on a
// production broker.

messaging-schema-apiSchemaCompatibilityValidator가 허용목록이고 AvroCompatibilityGate가 거부목록인 것(그쪽 §12.3)과 같은 축의 판단이며, 여기서는 허용목록을 고른 이유가 명시돼 있다.

네 번째 검사에 순서 문제가 있다. TLS_PROTOCOL_UNSPECIFIEDTLS_PROTOCOL_NOT_ACCEPTED 뒤에 있는데, 빈 목록은 filter를 통과하는 요소가 없으므로 unsupported가 비어 있어 앞 검사를 지나간다. 결과적으로 빈 목록은 네 번째에서 잡힌다 — 동작은 맞다. 다만 읽는 순서와 논리 순서가 다르다.

4.5 MessageSecurityValidator — 시작 시 네 가지
// :9-12
 * <p>These checks are boot failures rather than warnings. An unencrypted production broker
 * connection or a shared producer/admin credential is not a degraded mode the platform can run in
 * safely; both are the kind of misconfiguration that stays invisible until it is exploited.
# 거절 조건
1 production && TLS 꺼짐
2 production && hostname 검증 꺼짐
3 producer와 consumer가 같은 credential id
4 admin이 producer/consumer와 같은 credential id
5 production && admin 존재

3·4번을 LinkedHashSet.add의 반환값으로 구현한다 — 추가에 실패하면 중복이다. 간결하고 정확하다.

5번이 (c) 원칙을 강제하는 지점이다 — 운영 런타임은 admin 자격증명을 아예 갖지 못한다.

1·2번이 BrokerTlsPolicy와 겹친다(§12.3).

4.6 BrokerAclManifest — 초과가 발견이다
// :113-118
 * <p>Excess is the finding, not the shortfall: a missing grant fails loudly on first use, while
 * an undeclared extra one sits unnoticed until it is abused.

undeclared(observed)가 관측 선언, missing(observed)가 선언 − 관측이다. 두 방향을 모두 계산하지만 javadoc이 어느 쪽이 발견인지 정한다.

Operation enum이 파괴적 여부를 상수에 담는다 — ALTER, DELETE, PURGEdestructive=true.

// :17-20
 * <p>Destructive permissions are named separately from ordinary ones. {@code DELETE_TOPIC} and
 * {@code PURGE} are not "write, but more"; they destroy data an application can never restore, so
 * an application runtime declaring one is rejected outright.

requireApplicationRuntime()이 파괴적 grant가 하나라도 있으면 MessagingConfigurationException("APPLICATION_HOLDS_DESTRUCTIVE_GRANT")을 던진다.

이 클래스 전체가 소비자 0이다(§12.1).

undeclared/missingSet<Grant>를 받는데, Grant는 record이므로 equals가 세 필드 전부를 비교한다. 즉 pattern이 문자열 정확 일치여야 한다 — 와일드카드 패턴(orders.*)을 브로커가 다르게 표현하면 오탐이 난다. javadoc에 언급 없음.

4.7 CredentialIds — 참조 자리에 비밀을 붙여넣는 사고
// :9-13
 * <p>The bounded slug pattern is not cosmetic. Credential ids reach log lines and metric tags, so
 * an unbounded id is a cardinality problem, and an id that looks like a secret is a leak. The
 * heuristic check rejects the most common accident: pasting the secret itself where the reference
 * belongs.

패턴 [a-z0-9][a-z0-9._-]{1,63} — 최소 2자, 최대 64자.

휴리스틱 접두사 다섯: bearer , basic , sk-, -----begin, eyj. 각각 HTTP Authorization, OpenAI 키, PEM 블록, base64 JWT 헤더({"eyJ)를 노린다.

패턴이 이미 대부분을 막는다. [a-z0-9._-]만 허용하므로 공백이 있는 bearer ·basic 는 패턴에서 이미 거절되고, -----begin은 첫 글자가 -라 거절된다. 실제로 휴리스틱만이 잡는 것은 sk-eyj뿐이다. 중복 방어이고 해롭지 않다.

4.8 DestinationAccessPolicy — 세 역할, 세 집합

publishable/consumable/administrable 셋이 전부 Set.copyOf로 불변화된다. denyAll()이 세 빈 집합이다.

// :10-12
 * <p>The platform checks this before the broker does. Relying only on broker ACLs means an
 * accidental publish surfaces as a generic authorization error at runtime, in the adapter, with no
 * record of which application module attempted it.

DestinationAccessValidator가 세 require* 메서드로 그 검사를 예외로 바꾼다 — 그리고 소비자가 0이다(§12.1).


5. 주요 실행 경로

자격증명 해석: 어댑터의 security configurer → registry.resolve(credentialId, now) → 캐시 유효하면 반환 → 아니면 compute 안에서 provider.resolve + provider.expiresAt → 새 CredentialRuntime 설치 → 옛 것 clear()

시작 검증(1): starter가 MessageSecurityValidator bean 생성 → validate(profile) 호출 지점은 starter가 소유

시작 검증(2): 어댑터 configurer가 BrokerTlsPolicy.validate(profile, enabledProtocols) 호출

발행 권한: DefaultMessagePublisheraccess.mayPublish(name) → false면 PublishResult(REJECTED, PUBLISH_FORBIDDEN)


6. 실패 경로와 복구/번역

코드 예외 위치
TLS_REQUIRED MessagingConfigurationException BrokerTlsPolicy
HOSTNAME_VERIFICATION_REQUIRED MessagingConfigurationException 같음
TLS_PROTOCOL_NOT_ACCEPTED MessagingConfigurationException 같음
TLS_PROTOCOL_UNSPECIFIED MessagingConfigurationException 같음
APPLICATION_HOLDS_DESTRUCTIVE_GRANT MessagingConfigurationException BrokerAclManifest(미사용)
DESTINATION_PUBLISH_DENIED MessageAuthorizationException DestinationAccessValidator(미사용)
DESTINATION_CONSUME_DENIED MessageAuthorizationException 같음(미사용)
DESTINATION_ADMIN_DENIED MessageAuthorizationException 같음(미사용)
(코드 없음) IllegalArgumentException × 5 MessageSecurityValidator
(코드 없음) IllegalArgumentException CredentialIds, 각 생성자
(코드 없음) IllegalStateException CredentialRuntime.material() 소거 후

보안 판정이 두 예외 계층으로 나뉜다. BrokerTlsPolicy는 안정 코드가 붙은 MessagingConfigurationException을 쓰고, MessageSecurityValidator는 코드 없는 IllegalArgumentException을 쓴다. 둘이 같은 두 검사(TLS·hostname)를 공유하는데도 그렇다 — §12.3, §17.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

지점 도구 보호
CredentialRuntimeRegistry.resolved ConcurrentHashMap 맵 자체
resolve compute(bin 락) key별 single-flight, fetch 정확히 한 번
CredentialRuntime.material 동기화 없음 (§17)

레코드 여섯(BrokerSecurityProfile, BrokerCredentialProfile 5변형, DestinationAccessPolicy, BrokerAclManifest, CredentialRotationPlan)은 전부 불변이다. BrokerTlsPolicy·MessageSecurityValidator·DestinationAccessValidator는 상태가 없거나 불변 참조만 갖는다.

수명주기 참여는 clearAll()뿐이고 "for shutdown"이라고 javadoc이 적는다. 그것을 부르는 코드가 저장소에 없다 — 종료 시 자격증명이 소거되지 않는다. §17.


8. 설정·기능 플래그·환경 차이

상수/기본값 위치
CredentialRuntime.DEFAULT_ROTATION_LEAD 30분 public
BrokerTlsPolicy.MINIMUM_PROTOCOL "TLSv1.2" public
BrokerTlsPolicy.ACCEPTED_PROTOCOLS {TLSv1.2, TLSv1.3} private
BrokerTlsPolicy() 기본 allowPlaintextOutsideProduction = true
CredentialIds.VALID [a-z0-9][a-z0-9._-]{1,63} private

production 플래그가 세 클래스의 분기 조건이다 — BrokerTlsPolicy, MessageSecurityValidator, 그리고 BrokerSecurityProfile의 필드. 그 값을 정하는 곳은 이 leaf 밖이다.

MINIMUM_PROTOCOL이 public이고 아무도 쓰지 않는다. ACCEPTED_PROTOCOLS가 private이므로 외부에서 허용 집합을 알려면 isAcceptable(String)을 부르거나 이 상수를 보는데, 상수는 최소값만 알려준다.


9. 퍼시스턴스/외부 시스템 세부

없다. CredentialProvider가 외부 비밀 저장소를 가리킬 수 있는 port이고 이 leaf에 구현이 없다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-security:test. BUILD SUCCESSFUL, 24 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
CredentialRuntimeRegistryTest 13 해석·캐시·회전·소거·경합 하 single-flight 실제 비밀 저장소
MessageSecurityValidatorTest 7 다섯 거절 조건 실제 부팅에서 호출되는지(→ starter가 bean 생성)
CredentialRotationContractTest 4 회전 시점 술어 CredentialRotationPlan이 쓰이는지

커버리지 공백 셋.

  • BrokerTlsPolicy를 겨냥한 테스트 클래스가 없다. 네 거절 조건과 허용목록 판정이 이 leaf의 테스트로 검증되지 않는다. 어댑터 쪽 KafkaSecurityConfigurerTest가 간접적으로 지나갈 수 있으나 그것은 다른 leaf의 레인이고 다른 것을 목표로 한다.
  • BrokerAclManifest를 겨냥한 테스트가 없다. undeclared/missing/requireApplicationRuntime 셋 다 미검증이다.
  • DestinationAccessValidator·DestinationAccessPolicy를 겨냥한 테스트가 없다.

12개 타입 중 5개가 이 leaf의 테스트에 등장하지 않는다. 그리고 그중 셋은 §12.1의 소비자 0 목록과 겹친다 — 쓰이지도 않고 테스트되지도 않는다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api"]
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 벤더 의존성 0
SecretLeakStaticScanTest(observability leaf) 이 leaf의 소스도 스캔 대상 — 콘솔 출력·민감 식별자 문자열 연결 금지
ArchUnit 전용 규칙 없음

네 번째가 이 leaf에 실질적이다. CredentialRuntime.toString()이 material을 빼고 id와 expiry만 담는 것, MessagingRedactor가 credential 키를 지우는 것과 함께 세 층의 방어를 이룬다 — 타입(char[]), 표현(toString), 정적 스캔.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/287-messaging-security-duplicate-checks.txt.

방법. 정규화된 이름(import dev.caskeleton.messaging.security.<Type>; 또는 dev.caskeleton.messaging.security.<Type>)으로 측정했다. messaging-observabilityCardinalityGuard처럼 동명 클래스가 있는 경우를 배제하기 위해서다.

12.1 Public surface reachability
타입 leaf 밖 파일 판정
BrokerSecurityProfile 8 활발
DestinationAccessPolicy 7 활발
BrokerTlsPolicy 6 어댑터 둘 + starter 셋
CredentialRuntimeRegistry 6 같음
BrokerCredentialProfile 5 활발
CredentialProvider 4 활발
CredentialRuntime 2
MessageSecurityValidator 1 starter bean
DestinationAccessValidator 0
BrokerAclManifest 0
CredentialRotationPlan 0
CredentialIds 0 package-private — 구조상 내부. 결함 아님

(a) 접근 검증기가 쓰이지 않고, 같은 검사가 다른 형태로 인라인돼 있다

DestinationAccessValidator.requirePublish는 예외를 던진다.

if (!policy.mayPublish(destination)) {
  throw new MessageAuthorizationException(
      "DESTINATION_PUBLISH_DENIED",
      "the producer credential may not publish to " + destination.value());
}

발행 경로는 정책을 직접 묻고 결과를 반환한다.

// DefaultMessagePublisher.java:170-176
if (!access.mayPublish(destination.name())) {
  return rejected(
      "PUBLISH_FORBIDDEN",
      "this application may not publish to '" + destination.name().value() + '\'',
      startedAt);
}

같은 판단, 다른 코드, 다른 실패 형태. DESTINATION_PUBLISH_DENIED(AUTHORIZATION 카테고리, 예외) vs PUBLISH_FORBIDDEN(CONFIGURATION 카테고리, PublishResult). 대시보드가 권한 거부를 세려면 두 어휘를 모두 알아야 하는데, 실제로 발생하는 것은 후자뿐이다. 그리고 FailureCategory가 다르다 — 권한 거부가 AUTHORIZATION이 아니라 CONFIGURATION으로 기록된다.

requireConsume/requireAdminister도 소비자가 없다 — 소비 경로가 조립되지 않고(§A19-MESSAGING-POLICY §17) admin 경로는 messaging-admin-runtime이 자체 검사를 할 수 있다.

(b) ACL 매니페스트 전체가 미사용이다

BrokerAclManifest는 선언·비교·거절 셋을 모두 갖춘 메커니즘이다 — requireApplicationRuntime()이 파괴적 grant를 가진 애플리케이션을 거절하고, undeclared(observed)가 브로커가 실제로 준 초과 권한을 찾는다. javadoc이 그 목적을 "The manifest is what the platform checks itself against at startup"이라고 적는다.

그 startup 검사를 하는 코드가 없다. 그리고 observed 집합을 만들려면 브로커에서 ACL을 읽어야 하는데, 그 읽기를 하는 코드도 없다 — messaging-admin-apiBrokerTopologyInspector가 후보이지만 이 leaf와 연결되지 않는다. 즉 미사용의 이유가 단순한 배선 누락이 아니라 관측 소스의 부재일 수 있다. 그것은 admin leaf가 답한다.

(c) 회전 계획 record가 미사용이고 그 술어가 다른 곳에 복제돼 있다 — §12.3.

12.2 Conditional sibling comparison

이 leaf에 bean은 없다. starter 쪽 sibling 셋의 조건은 동일(@ConditionalOnMissingBean)하고 소비가 다르다.

bean 주입처
BrokerTlsPolicy KafkaMessagingAutoConfiguration, RabbitMessagingAutoConfiguration
CredentialRuntimeRegistry 같음
MessageSecurityValidator 없음 — bean만 존재

세 번째가 messaging-policyRetryDecisionEngine(그쪽 §12.1)과 같은 형태다. 다만 차이가 있다 — MessageSecurityValidator직접 호출 지점이 있을 수 있다(starter가 bean을 만들면서 같은 파일에서 부를 수 있다). 그 확인은 starter leaf가 소유한다.

12.3 Duplicate mechanism sweep

(a) TLS posture 검사가 두 클래스에 있고 엄격도가 다르다

MessageSecurityValidator BrokerTlsPolicy
TLS 필수 production && !tlsEnabled !tlsEnabled && (production || !allowPlaintextOutsideProduction)
hostname 검증 production && !hostnameVerification tlsEnabled && !hostnameVerification
프로토콜 버전 없음 허용목록 + 빈 목록 거절
예외 IllegalArgumentException MessagingConfigurationException
안정 코드 없음 4개
호출자 starter bean(주입처 없음) 어댑터 둘

hostname 검증의 조건이 다르다. MessageSecurityValidator는 production에서만 요구하고, BrokerTlsPolicyTLS가 켜져 있으면 언제나 요구한다. 즉 비운영에서 TLS를 켜고 hostname 검증을 끈 구성은 후자가 거절하고 전자는 통과시킨다. 후자가 더 엄격하고, 후자가 실제로 호출되는 쪽이다.

두 클래스가 같은 BrokerSecurityProfile을 받는다. 어느 쪽이 정본인지 코드가 말하지 않는다.

(b) 회전 술어가 두 번 구현돼 있다

// CredentialRotationPlan.isDue(now)         — 소비자 0
return expiresAt.map(expiry -> !now.isBefore(expiry.minus(rotateBefore))).orElse(false);

// CredentialRuntime.isDueForRotation(now)   — 사용됨
return expiresAt.map(expiry -> !now.isBefore(expiry.minus(rotationLead))).orElse(false);

isExpired도 같다.

// CredentialRotationPlan.isExpired(now)
return expiresAt.map(expiry -> !now.isBefore(expiry)).orElse(false);
// CredentialRuntime.isExpired(now)
return expiresAt.map(expiry -> !now.isBefore(expiry)).orElse(false);

글자까지 동일하다. 필드 이름만 rotateBefore vs rotationLead로 다르다. CredentialRotationPlan은 material을 갖지 않는 순수 계획 record이고 CredentialRuntime은 material을 갖는 런타임 상태다 — 관심사 분리로는 말이 되지만, 술어가 복제된 채로 한쪽만 쓰인다.

CredentialRotationContractTestCredentialRotationPlan을 테스트한다. 즉 쓰이지 않는 쪽이 테스트되고 쓰이는 쪽의 같은 술어는 그 테스트가 덮지 않는다. (CredentialRuntimeRegistryTest가 간접적으로 덮는다.)

(c) 권한 검사 두 형태 — §12.1(a).

(d) 자격증명 참조 검증이 다른 family에도 있는가

git grep으로 credential id 패턴 검증을 저장소 전역에서 찾으면 이 leaf의 CredentialIds가 유일하다. notification·grpc family는 자기 자격증명 모델을 갖지만 messaging의 것을 쓰지 않는다 — 경계가 분명하므로 중복 경쟁이 아니다.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
BrokerCredentialProfile javadoc: 어떤 변형도 비밀을 담지 않음 다섯 record 전부 credentialId 하나 일치
CredentialRuntime javadoc: char[]로 보관하고 clear()가 덮어씀 확인 일치
BrokerAclManifest javadoc: "what the platform checks itself against at startup" 호출자 0 불일치
DestinationAccessPolicy javadoc: "The platform checks this before the broker does" mayPublish가 발행 경로에서 호출됨 일치(다만 validator 경유 아님)
CredentialRuntimeRegistry.clearAll javadoc: "for shutdown" 호출자 0 불일치
MessageSecurityValidator javadoc: "boot failures rather than warnings" starter가 bean 생성. 호출 지점은 starter가 소유 미확인
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift)

13. Git/설계 문서에서 확인한 변화와 실패 기록

위치 이전 상태 그것이 만든 실패
CredentialRuntimeRegistry.resolve 주석 get → fetch → put → clear, 동기화 없음 두 스레드가 같은 자격증명을 회전 → 진 쪽 교체본이 맵에서 사라지고 소거도 안 됨(소유자 없는 비밀이 힙에 잔류), 그리고 진 쪽이 이긴 쪽이 사용 중인 material을 소거
BrokerTlsPolicy 프로토콜 검사 주석 거부목록 SSL·TLSv0.9·PLAINTEXT·오타가 전부 통과 → JVM이 인식 못 하는 문자열은 JVM 기본값으로 협상, 즉 이 정책이 막으려던 결과

두 번째가 messaging-schema-api §12.3의 허용목록/거부목록 축과 같은 주제이고, 여기서는 거부목록이 실제로 뚫린 기록이 남아 있다.

첫 번째는 이 저장소가 반복하는 "정확히 한 번" 주제의 보안 판본이다 — messaging-transport-spi의 세대 close, messaging-policy의 permit 반납과 같은 계열이며, 여기서는 실패의 결과가 비밀 잔류다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-287 command evidence/raw/287-messaging-security-duplicate-checks.txt 12타입 정규화 이름 기준 참조 수, 소비자 0인 넷, 접근 검사 두 형태 나란히, TLS 검사 두 클래스의 조건 차이, 회전 술어 두 복사본, 실제 소비자 목록 정적 검색. 리플렉션·파생 프로젝트 미포함
EVD-288 command ./gradlew :messaging:messaging-security:test --rerun-tasks BUILD SUCCESSFUL, 24 / 0 / 0 BrokerTlsPolicy·BrokerAclManifest·접근 정책 미검증

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 어떤 변형도 비밀을 담지 않는 이유 — BrokerCredentialProfile javadoc
  • char[]이 타입 수준 통제인 이유 — CredentialRuntime javadoc
  • material을 복사해 반환하는 이유 — material() javadoc
  • 만료 이전에 회전하는 이유 — CredentialRuntime·CredentialRotationPlan javadoc
  • single-flight가 필요한 이유와 두 개의 이전 결함 — resolve 주석
  • 소거 순서(설치 후, 교체한 스레드가) 이유 — 같은 주석
  • hostname 검증 부재가 평문보다 나쁜 이유 — BrokerTlsPolicy javadoc
  • 허용목록을 고른 이유와 거부목록이 뚫린 기록 — 같은 파일 주석
  • 보안 검사가 경고가 아니라 부팅 실패인 이유 — MessageSecurityValidator javadoc
  • 세 자격증명을 분리하는 이유 — BrokerSecurityProfile javadoc
  • 초과 권한이 발견인 이유 — BrokerAclManifest javadoc
  • 파괴적 연산을 따로 이름 붙인 이유 — 같은 javadoc
  • credential id를 슬러그로 제한하는 이유 — CredentialIds javadoc
  • 플랫폼이 브로커보다 먼저 검사하는 이유 — DestinationAccessPolicy·DestinationAccessValidator javadoc

추론

  • DestinationAccessValidator가 미사용인 것은 발행 경로가 예외 대신 PublishResult를 반환하기로 했기 때문이다 → 추론. 두 형태의 존재는 관측이고 인과는 추론이다.
  • BrokerAclManifest가 미사용인 것은 브로커에서 ACL을 읽는 코드가 없기 때문이다 → 추론. 읽기 코드 부재는 관측이다.
  • CredentialRotationPlan이 미사용인 것이 CredentialRuntime으로 흡수된 결과인지 → 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 12개 타입 954줄 전문의 계약
  • 24개 테스트가 통과하고 무엇을 단언하는지, 그리고 5개 타입이 테스트에 등장하지 않는다는 것
  • 정규화 이름 기준 참조 수와, 소비자 0인 셋(+package-private 하나)
  • 어댑터 둘이 BrokerTlsPolicy·CredentialRuntimeRegistry를 실제로 쓴다는 것
  • 같은 판단이 두 형태로 존재하는 세 쌍(접근 검사, TLS posture, 회전 술어)과 그중 TLS는 엄격도가 실제로 다르다는 것
  • clearAll()의 호출자가 없다는 것

확인하지 못한 것

  • MessageSecurityValidator.validate가 실제로 호출되는지. starter가 bean을 만들고, 같은 파일에서 직접 호출할 가능성이 있다. starter leaf가 답한다.
  • 브로커에서 ACL을 읽는 경로가 존재하는지 — messaging-admin-apiBrokerTopologyInspector가 후보다.
  • compute 안에서 provider.resolve가 실제 저장소를 호출할 때의 지연. 구현이 없어 관측할 수 없다.
  • CredentialRuntime.material 필드의 가시성 문제가 실제로 발생하는지 — 현재 경로에서는 창이 좁다.
  • BrokerAclManifest.Grantpattern 정확 일치가 실제 브로커 표현과 맞는지.

17. 손볼 것

P2 — 같은 TLS posture를 두 클래스가 다른 엄격도로 검사한다
  • 사실. MessageSecurityValidator는 hostname 검증을 production && !hostnameVerification일 때만 요구하고, BrokerTlsPolicytlsEnabled && !hostnameVerification일 때 요구한다. 전자는 코드 없는 IllegalArgumentException, 후자는 안정 코드가 붙은 MessagingConfigurationException을 던진다. 둘 다 같은 BrokerSecurityProfile을 받고, 후자만 어댑터에서 실제로 호출된다.
  • 근거. evidence/raw/287 §D.
  • 왜 문제인가. 비운영에서 TLS를 켜고 hostname 검증을 끈 구성을 두 검사가 다르게 판정한다. 그리고 이 leaf 자신의 javadoc이 그 구성을 "looks encrypted in every dashboard while accepting any certificate a man in the middle presents"라고 부른다 — 즉 더 느슨한 쪽이 그 위험을 통과시킨다. 실패 형태도 달라서 운영자가 두 어휘를 알아야 한다.
  • 확인 방법. evidence/raw/287 §D 재실행. 또는 두 validate 메서드 대조.
  • 후보. (a) MessageSecurityValidatorBrokerTlsPolicy에 위임한다. (b) 두 클래스의 책임을 나눈다 — TLS는 후자, 자격증명 분리는 전자.
  • 다음 단계. CASE 후보 + REFERENCE 후보. "같은 불변식을 두 곳에서 검사하면 느슨한 쪽이 통과 경로가 된다"가 재사용 가능한 기준이다.
P2 — 권한 거부가 AUTHORIZATION이 아니라 CONFIGURATION으로 기록된다
  • 사실. DestinationAccessValidator.requirePublishMessageAuthorizationException("DESTINATION_PUBLISH_DENIED")을 던지고 그 카테고리는 AUTHORIZATION이다. 소비자가 0이다. 실제 발행 경로는 access.mayPublish를 직접 묻고 rejected("PUBLISH_FORBIDDEN", ...)을 반환하는데, rejected(...)FailureCategory.CONFIGURATION을 붙인다.
  • 근거. evidence/raw/287 §C. DefaultMessagePublisher.java:104-116(rejected의 카테고리).
  • 왜 문제인가. FailureCategory는 "stable classification a retry engine, DLQ router, and dashboard all agree on"이다(messaging-core-api §4.12). 권한 거부가 구성 오류로 분류되면 보안 대시보드가 그것을 보지 못하고, 구성 오류 알림이 권한 거부로 오염된다. 그리고 AUTHORIZATION 카테고리를 쓰는 유일한 코드가 미사용 클래스에 있다.
  • 확인 방법. MessageAuthorizationExceptionCATEGORY 상수와 DefaultMessagePublisher.rejected의 카테고리 대조.
  • 후보. 발행 경로가 권한 거부에 AUTHORIZATION 카테고리를 붙이거나, DestinationAccessValidator를 쓰고 예외를 PublishResult로 번역한다.
  • 다음 단계. CASE 후보. messaging-runtime-core leaf와 공동 소유.
P3 — ACL 매니페스트 전체가 쓰이지 않는다
  • 사실. BrokerAclManifest의 세 메서드(requireApplicationRuntime, undeclared, missing)와 두 enum이 소비자 0이다. javadoc은 "The manifest is what the platform checks itself against at startup"이라고 한다.
  • 근거. evidence/raw/287 §A·§B.
  • 왜 문제인가. "애플리케이션 런타임은 파괴적 권한을 갖지 않는다"는 이 leaf의 핵심 원칙 중 하나이고, MessageSecurityValidator가 admin 자격증명의 부재만 검사한다. 브로커가 producer 자격증명에 DELETE를 준 경우는 아무도 보지 않는다.
  • 확인 방법. git grep -l 'BrokerAclManifest' -- src ':!src/messaging/messaging-security' → 없음.
  • 후보. startup 검사에 배선하거나, 브로커 ACL 읽기가 없으면 그 사실을 javadoc에 적는다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 "브로커 ACL을 읽는 경로가 있는가"에 걸리고, 그것은 messaging-admin-api가 답한다.
P3 — 종료 시 자격증명 소거가 호출되지 않는다
  • 사실. CredentialRuntimeRegistry.clearAll()의 javadoc이 "Clears every held credential, for shutdown"이라고 하고, 호출자가 저장소에 없다.
  • 근거. git grep -n 'clearAll' -- src.
  • 왜 문제인가. 이 leaf 전체가 "비밀이 힙에 남지 않게 한다"를 목적으로 하고(char[], clear(), 회전 시 즉시 소거), 종료 경로에서 그 마지막 단계가 빠져 있다. 프로세스가 끝나면 힙도 사라지지만, 종료가 느리거나 힙 덤프가 뜨는 경우가 정확히 이 통제가 노리는 상황이다.
  • 확인 방법. git grep -n 'clearAll' -- src → 선언과 테스트만.
  • 후보. MessagingShutdownLifecycle이나 DisposableBean에 연결한다.
  • 다음 단계. CASE 후보. messaging-transport-spi §12.1의 8단계 종료 계약과 같은 맥락이다.
P3 — 회전 술어가 두 번 구현돼 있고, 쓰이지 않는 쪽이 테스트된다
  • 사실. CredentialRotationPlan.isDue/isExpiredCredentialRuntime.isDueForRotation/isExpired가 글자까지 같다. 전자는 소비자 0이고 전용 테스트(CredentialRotationContractTest, 4개)가 있다.
  • 근거. evidence/raw/287 §E.
  • 왜 문제인가. 테스트가 고정하는 것과 실행되는 것이 다른 객체다. 한쪽만 고치면 다른 쪽은 조용히 다른 시점에 회전한다.
  • 확인 방법. 두 메서드 본문 대조.
  • 후보. CredentialRuntimeCredentialRotationPlan을 필드로 갖고 위임하거나, 계획 record를 제거한다.
  • 다음 단계. REFERENCE 후보(같은 술어가 두 타입에 있으면 하나가 다른 하나를 부른다).
P3 — 자격증명 해석이 맵 bin 락 안에서 외부 I/O를 한다
  • 사실. resolveresolved.compute(credentialId, (key, existing) -> { ... provider.resolve(key) ... }) 형태다. CredentialProvider.resolve는 외부 비밀 저장소를 호출할 수 있는 port다.
  • 근거. CredentialRuntimeRegistry.java:71-86.
  • 왜 문제인가. single-flight를 얻은 대가다 — 같은 credential id를 요청하는 다른 스레드는 저장소 왕복 동안 막힌다. 그것이 의도이고 옳다. 다만 ConcurrentHashMap의 bin은 키가 공유하므로 해시가 충돌하는 다른 credential id도 함께 막힌다. 그리고 저장소가 느려지면 그 지연이 발행 경로로 전파된다 — 타임아웃이 없다.
  • 확인 방법. provider.resolve 호출 위치가 람다 안임을 확인.
  • 후보. 현 구조를 유지하되 CredentialProvider javadoc에 "구현은 유한 시간 안에 반환해야 한다"를 명시한다.
  • 다음 단계. REFERENCE 후보(맵 갱신 함수 안에서 I/O를 하면 그 지연이 락 범위가 된다).
P3 — 다섯 타입이 이 leaf의 테스트에 등장하지 않는다
  • 사실. BrokerTlsPolicy·BrokerAclManifest·DestinationAccessPolicy·DestinationAccessValidator·BrokerCredentialProfile을 겨냥한 테스트가 없다.
  • 근거. 세 테스트 클래스 전수.
  • 왜 문제인가. BrokerTlsPolicy실제로 배선된 클래스다 — 어댑터 둘이 호출한다. 네 거절 조건과 허용목록 판정이 이 leaf의 레인에서 검증되지 않는다. 어댑터 테스트가 간접적으로 지나가더라도 그것은 다른 목표를 가진 레인이다.
  • 확인 방법. find src/test -name '*Test.java' → 셋.
  • 후보. BrokerTlsPolicy의 네 거절 조건과 허용/거부 경계를 겨냥한 테스트를 추가한다.
  • 다음 단계. REFERENCE 후보(배선된 게이트는 자기 leaf 레인에서 검증한다).
P3 — CredentialRuntime.material이 동기화되지 않는다
  • 사실. private char[] materialvolatile이 아니고 clear()가 그것을 교체한다. clearAll()은 락 없이 순회한다.
  • 근거. CredentialRuntime.java:29,129-132, CredentialRuntimeRegistry.java:129-132.
  • 왜 문제인가. 정상 경로(compute 안 소거)에서는 ConcurrentHashMap이 happens-before를 준다. clearAll() 경로에는 그 보장이 없다 — 다른 스레드가 소거된 배열의 옛 참조를 보고 이미 지워진 material을 읽을 수 있다(0으로 채워진 값). 실질 위험은 낮고 방향도 안전(비밀 유출이 아니라 잘못된 값)하다.
  • 확인 방법. 필드 선언 확인.
  • 후보. materialvolatile로 하거나 clearAll()compute 기반으로 바꾼다.
  • 다음 단계. REFERENCE 후보(가변 필드로 상태 전이를 표현하면 가시성을 함께 정한다).
확인된 설계(문제 아님)
  • 어떤 자격증명 프로파일 변형도 비밀을 담지 않고 참조만 갖는 것, 그리고 sealed로 닫은 것
  • material을 char[]로 보관하고 반환 시 복사하며 소거 시 덮어쓰는 세 통제
  • toString()이 material을 담지 않는 것
  • key별 single-flight와 "설치 후 소거, 교체한 스레드만" 순서
  • 만료가 아니라 만료 이전에 회전하는 것, 만료를 모르면 회전 대상이 아닌 것
  • TLS 프로토콜을 허용목록으로 판정한 것과 그 이유가 실패 이력으로 남은 것
  • hostname 검증 부재를 평문보다 나쁜 실패로 분류한 것
  • producer·consumer·admin 자격증명 분리와 운영에서 admin 금지
  • credential id 슬러그 제한과 비밀-모양 접두사 휴리스틱
  • 파괴적 연산을 enum 상수에 표시한 것

Source anchors

id kind path revision what it proves limitations
MSC-001 registry src/config/architecture/modules.json 21234e38 deps 1개, memberships ["app-bootstrap"] 선언
MSC-002 build messaging-security/build.gradle same 벤더 의존성 0
MSC-003 code .../security/CredentialRuntime.java 전문 same §4.2 세 통제, 회전 술어 material 미동기화(§17)
MSC-004 code .../security/CredentialRuntimeRegistry.java 전문 same §4.1 single-flight와 두 이전 결함 clearAll 호출자 없음
MSC-005 code .../security/BrokerTlsPolicy.java 전문 same §4.4 네 거절과 허용목록 이력 전용 테스트 없음
MSC-006 code .../security/MessageSecurityValidator.java same §4.5 다섯 거절 TLS 검사가 §4.4와 겹침
MSC-007 code .../security/BrokerAclManifest.java same §4.6 초과=발견, 파괴적 연산 분리 소비자 0
MSC-008 code .../security/{DestinationAccessPolicy,DestinationAccessValidator}.java same §4.8 세 역할, 검증기의 세 코드 검증기 소비자 0
MSC-009 code .../security/{BrokerSecurityProfile,BrokerCredentialProfile,CredentialIds,CredentialProvider,CredentialRotationPlan}.java same 역할 분리, sealed 5변형, id 검증, port 계획 record 소비자 0
MSC-010 test CredentialRuntimeRegistryTest (13) same 해석·회전·소거·경합 실제 저장소 없음
MSC-011 test MessageSecurityValidatorTest (7) same 다섯 거절 조건
MSC-012 test CredentialRotationContractTest (4) same 회전 시점 술어 미사용 타입을 테스트
MSC-013 cross-leaf code messaging-kafka/.../KafkaSecurityConfigurer.java, messaging-rabbit/.../RabbitSecurityConfigurer.java same BrokerTlsPolicy·CredentialRuntimeRegistry의 실제 소비 각 leaf SSOT가 소유
MSC-014 cross-leaf code messaging-runtime-core/.../DefaultMessagePublisher.java:170-176 same 인라인 권한 검사와 그 코드·카테고리 해당 leaf SSOT가 소유
MSC-015 assembly messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java same 세 bean 생성 해당 leaf SSOT가 소유
EVD-287 command evidence/raw/287-messaging-security-duplicate-checks.txt same §12.1·§12.3 전부 정적 검색
EVD-288 command ./gradlew :messaging:messaging-security:test --rerun-tasks same 24 / 0 / 0 5개 타입 미검증

A19-MESSAGING-SPRING-BOOT-STARTER. messaging-spring-boot-starter

분석 중에는 messaging/MESSAGING-SPRING-BOOT-STARTER.md 파일이었다. 456줄.

messaging-spring-boot-starter 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) — src/main production 28파일 3,528줄 + src/test 10파일 2,349줄 축자 통독 완료. STRUCTURAL_ONLYgradle.lockfile 하나. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-spring-boot-starter SSOT owner: messaging-spring-boot-starter integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • runtime_memberships: ["app-bootstrap"] — 출하. 이 리프가 messaging 폐포 전체를 실행 클래스패스에 올린다
  • 자동 설정 등록: MessagingPlatformRootAutoConfiguration 하나
  • 다만 app-bootstrapapplication.yml 어디에도 app.messaging.enabled 가 없다. 클래스패스에는 있고 꺼져 있다
파일 LOC 역할
MessagingCoreAutoConfiguration 480 정책·전송·관측 빈 26개 + 발행자 + 런타임 설치
MessagingConfigurationCompiler 331 문서화된 설정 → 플랫폼 프로파일
MessagingSettings 301 app.messaging 바인딩 + 중첩 4클래스
DefaultBatchMessagePublisher 254 배치 팬아웃 + 마감
MessagingConfigurationKeyValidator 227 바인딩되지 않는 키 거부
MessagingReliabilityAutoConfiguration 185 발신함·수신함 운영 빈
DestinationSettings 177 목적지 한 항목(중첩 record 7)
KafkaMessagingAutoConfiguration 170 Kafka 검증기·보안 설정기·생산자·전송
MessagingProviderSelection 150 닫힌 레지스트리에서 전송 하나 선택
MessagingShutdownLifecycle 124 승인 차단 → 배수
RabbitMessagingAutoConfiguration 98 검증기·분류기·보안 설정기 (전송 없음)
MessagingCredentialRequirementValidator 92 운영 프로파일에 자격 출처 요구
MessagingAdminAutoConfiguration 86 관리 평면(별도 스위치)
MessagingPrefixMigrationValidator 83 죽은 접두 거부
MessagingEndpoint 78 읽기 전용 actuator
PublishResults 71 예외 → 결과 변환
BrokerSettings 69 브로커 한 항목(두 가족 한 record)
MessagingOutboxRelayLifecycle 69 중계 구동
MessagingAdminDurabilityValidator 68 비내구 저널 위 운영 프로파일 거부
DefaultBlockingMessagePublisher 65 블로킹 파사드
ValidatedDestinationRegistry 59 검증 통과 목적지
CompiledMessagingConfiguration 53 컴파일 결과 4묶음
BrokerSecuritySettings 50 보안 한 항목(비밀 없음)
StartupProfileValidation 46 검증기를 실제로 부르는 어댑터
DefaultReactiveMessagePublisher 39 Reactor 파사드
MessagingPlatformRootAutoConfiguration 36 마스터 조건 소유
MessageContracts 35 메시지 계약 홀더
ReactiveMessagePublisher 32 Reactor 인터페이스

main 총 28파일 / 3,528줄.

Coverage ledger
scope count disposition reason
main/java/** 28 FULL_READ 3,528줄. 위 표가 전부
main/resources/META-INF/spring/*.imports 1 FULL_READ 1줄
test/java/** 10 FULL_READ 2,349줄
build.gradle 1 FULL_READ 66줄 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일 — 생성물

UNCLASSIFIED 0.

이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판은 큰 파일 아홉만 적고 "나머지 18파일 — " 로 닫았다. 그 "나머지" 안에 §17.4 가 있었다.


1. 하나의 뿌리가 조건을 소유한다

@AutoConfiguration
@ConditionalOnProperty(prefix = MessagingSettings.PREFIX, name = "enabled", havingValue = "true")
@EnableConfigurationProperties(MessagingSettings.class)
@Import({MessagingCoreAutoConfiguration.class, MessagingProviderSelection.class,
         MessagingReliabilityAutoConfiguration.class, MessagingAdminAutoConfiguration.class})
public class MessagingPlatformRootAutoConfiguration {}

javadoc 이 이전 상태와 수정을 적는다.

"The starter registered five auto-configurations directly, and not one carried a messaging master condition — putting the starter on the classpath assembled the platform… one root owning the condition, importing children that carry none, so a bean added to any child next month is gated without anyone remembering to repeat a condition."

그리고 제공자 선택의 이전 상태도 적는다.

"Kafka and Rabbit were each conditioned on their client class being present, so an application that happened to have both libraries — a transitive dependency is enough — assembled both providers and published through whichever bean won. Selection now reads app.messaging.broker against a closed registry, and a value outside it is a startup error rather than a context with no provider at all."

꺼진 상태의 계약도 명시된다 — 빈도, 클라이언트도, 스레드도, 결속된 상세 이름공간도 없다. MessagingStarterOffContractTest 가 그것을 빈 이름과 살아 있는 스레드 로 붙든다.

2. 선택은 닫힌 레지스트리이고, 등록과 조립은 다르다

MessagingProviderSelection 에 지도가 셋이다.

REGISTERED_BROKERS        = {kafka: org.apache.kafka.clients.producer.Producer,
                             rabbit: com.rabbitmq.client.Channel}
PROVIDER_CONFIGURATIONS   = {kafka: KafkaMessagingAutoConfiguration,
                             rabbit: RabbitMessagingAutoConfiguration}
BROKERS_WITHOUT_A_TRANSPORT = {rabbit: "…ships its validators and security configuration but no
                                        MessagingTransport…"}

셋째 지도가 이 클래스의 판단이다. 등록되어 있다는 것과 조립할 수 있다는 것을 분리했고, 그 이유를 적었다 — Rabbit 을 고르면 핵심 설정 깊은 곳에서 MessagingTransport 빈이 없다는 오류가 나는데, 그것은 운영자에게 빈이 없다고만 말하지 고른 전송이 완성되지 않았다고는 말하지 않는다.

결과로 오늘 조립 가능한 전송은 kafka 하나다. RabbitMessagingAutoConfiguration 98줄은 선택 단계에서 거부되므로 어떤 경로로도 도달하지 않는다(§12.3).

3. 설정이 프로파일이 된다

MessagingConfigurationCompiler 가 닫는 것은 기능이 아니라 바인더의 부재다.

"docs/messaging/configuration-reference.md described destination, broker and security sections; the only thing that bound was four flags… So a deployment that followed the documentation configured nothing, and nothing said so — which is the worst of the three possible outcomes, the other two being 'it works' and 'it refuses to start'."

컴파일과 검증을 나눈 이유도 적혀 있다. 컴파일은 객체 모델이 표현할 수 없는 것만 본다 — 목적지의 브로커가 존재하는지, 사후 처리 목적지가 선언되었는지, 보안 항목이 실재하는 브로커를 지키는지. 프로파일이 자체로 정합한지는 DestinationProfileValidator 의 질문이고 레지스트리 전체에 대해 던져진다. 그래서 설정으로 만든 프로파일과 빈으로 선언한 프로파일이 같은 규칙을 받는다.

그리고 모든 거부가 키를 부른다. 타입을 부르는 오류는 운영자가 고칠 줄을 알려 주지 않기 때문이다.

4. 시작 프로파일 검증

StartupProfileValidation 이 이 가족에서 이미 한 번 고쳐진 결함을 기록한다.

"The Kafka, RabbitMQ and security validators were all beans and none of them was injected anywhere: the context published a validator per broker and validated nothing."

수정의 두 판단이 적혀 있다 — afterPropertiesSet 으로 돌려 컨텍스트 구성 중에 실패하게 한 것, 그리고 프로파일을 Supplier 로 받아 애플리케이션 선언 빈과 설정에서 컴파일된 프로파일 두 출처 를 모두 보게 한 것.

"a validator that saw only one of the two would leave the other half of a deployment's configuration unchecked. Which half went unchecked would depend on how the deployment happened to be written, which is the worst possible rule."

5. 신뢰성 배선의 원칙

"Every bean here is conditional on the application having supplied the corresponding repository. The platform cannot provide those: they write inside the application's own transaction, against the application's own datasource, and a default implementation would silently write to the wrong place — or to nowhere at all, which is worse because the outbox would look healthy while nothing was ever staged."

정리 작업과 중계의 처리가 갈리고 그 이유도 적혀 있다.

"The cleanup jobs are beans but no scheduler is registered for them. Scheduling is the application's decision: a service running several replicas usually wants one of them to run cleanup, and auto-registering a fixed-rate task would have every replica delete the same rows."

"The relay is the opposite case and is driven here. Its claims are fenced by owner and token under SKIP LOCKED, so every replica running one is safe, while nobody running one is a table that fills up behind a business transaction that reported success."

6. 종료 순서가 두 수명 주기의 phase 로 표현된다

MessagingOutboxRelayLifecycle.getPhase()  = Integer.MAX_VALUE
MessagingShutdownLifecycle.getPhase()     = Integer.MAX_VALUE - 1024

SmartLifecycle 은 내림차순으로 멈추므로 중계가 먼저, 승인 차단과 배수가 다음이다. 두 클래스의 javadoc 이 서로를 근거로 든다 — 중계가 발행 중일 때 승인을 닫으면 그 회차의 행이 모호해지고, 그 모호함이야말로 배수가 없애려는 것이다. 그리고 브로커 연결을 쥔 빈(@Bean(destroyMethod = "close") 인 생산자)은 Lifecycle 이 아니므로 컨텍스트가 destroyBeans() 에 도달할 때, 즉 두 수명 주기가 모두 끝난 뒤에 닫힌다. 순서가 맞는다.

10. 테스트 레인

10파일 2,349줄.

파일 무엇을 붙드나
MessagingAutoConfigurationTest 546 빈 조립·바인딩·모순 프로파일 거부·접두 이관·저널 내구성·자격 요구
MessagingConfigurationBindingTest 320 문서를 실행한다docs/messaging/configuration-reference.md 의 YAML 블록을 꺼내 컨텍스트를 띄운다. 그리고 거부 9종
BatchPublisherTest 316 인덱스별 결과·동기 실패·마감·지연된 거부
MessagingStarterOffContractTest 254 꺼짐=빈 0·스레드 0, 선택 계약, Rabbit 거부
MessagingLiveRoundTripQualificationTest 217 Testcontainers Kafka 4.1.0 에 실제로 바이트를 보내고 읽어 온다
MessagingOutboxRelayLifecycleTest 203 컨텍스트가 중계를 실제로 돌리는지
BlockingFacadeTest · ReactiveFacadeTest 148 · 134 마감·모호 처리 / 차가운 Mono
MessagingEndpointTest 133 보고 내용·쓰기 연산 0
MessagingShutdownLifecycleTest 78 승인 차단이 배수보다 먼저

두 테스트가 이 리프의 검증 태도를 규정한다.

문서를 실행한다. MessagingConfigurationBindingTest.documented() 가 마크다운에서 ```yaml 블록을 뽑아 YamlPropertySourceLoader 로 올린다. 문서를 고쳐 바인더가 감당 못 하면 여기서 깨지고, 바인더를 고쳐 문서가 없는 모양을 서술하게 되어도 깨진다.

가짜가 결함을 가리는 것을 막는다. selectingRabbitIsRefused 의 주석이 자기 이전 판을 기록한다.

"This test used to run under withAPublisher() and assert the context started. The fake MessagePublisher tripped @ConditionalOnMissingBean and removed the very bean whose missing dependency is the defect — so a configuration that cannot start in any deployment passed as 'assembles Rabbit and not Kafka'."

그리고 그 교훈을 지키는 가드 테스트(aFakePublisherDoesNotHideAnUnassemblableTransport)를 따로 둔다.

12. negative-space probes

12.1 도달성. 이 리프는 app-bootstrap 에 출하되고 자동 설정이 등록된다. 그런데 app-bootstrapapplication.yml·application-{local,dev,prod}.yml 어디에도 app.messaging.enabled 가 없다. 클래스패스에 있고 꺼져 있다. 그래서 이 리프의 판정은 전부 "속성 하나를 켜는 날" 의 것이다 — 그리고 그 속성을 켜는 것이 곧 이 스타터를 채택하는 행위다.

12.2 대조군 — 검증기를 부르는가. grpc-spring-boot-starter 는 시작 검증기를 만들어 놓고 부르지 않는다. 이쪽은 StartupProfileValidation 으로 실제로 부른다 — 다만 셋 중 하나가 빠져 있다(§17.2).

12.3 도달하지 않는 설정 클래스. RabbitMessagingAutoConfiguration 98줄은 PROVIDER_CONFIGURATIONS 에 등록되어 있지만 selectedBrokerrabbit 을 먼저 거부하므로 Selector.selectImports 가 이 클래스 이름을 돌려주는 경로가 없다. 죽은 코드이되 의도된 죽은 코드다 — 전송이 생기는 날 BROKERS_WITHOUT_A_TRANSPORT 에서 항목이 빠지면 살아난다. 그 의도가 지도 이름과 javadoc 에 적혀 있다.

12.4 드리프트. 등록 파일이 뿌리 하나만 담고, 그 뿌리가 넷을 가져온다. 서술과 일치한다.

12.5 설정처럼 보이지만 상수인 것. MessagingConfigurationCompiler.credential(...) 의 넷째 매개변수 Supplier<Boolean> required 는 호출처 셋 모두 () -> true 다(§17.4).

12.6 두 설정 경로의 비대칭. 이 리프는 "빈으로 선언한 프로파일과 설정으로 만든 프로파일이 같은 규칙을 받아야 한다" 를 반복해서 근거로 든다. 그런데 DestinationSettings.Retry 에는 retryableCategories·nonRetryableCategories 에 대응하는 키가 없다(§17.5).

12.7 보안 설정기를 부르는 곳이 없다. 저장소 전체에서 KafkaSecurityConfigurer 를 언급하는 production 코드는 이 리프의 빈 선언 한 줄뿐이다. 나머지는 자기 자신과 자기 테스트다(§17.1).

16. 확인하지 못한 것

  • 애플리케이션이 저장소 빈을 공급한 상태로 컨텍스트를 세우지 않았다. 저장소에 그런 애플리케이션이 없다.
  • @ConditionalOnBean 의 평가 순서를 실제 컨텍스트로 재현하지 않았다(§17.3). 스프링의 문서화된 제약으로 판정했다.
  • §17.1 을 TLS·SASL 을 요구하는 실제 브로커에 붙여 재현하지 않았다. 조립되는 생산자 설정 맵의 성분 전부(bootstrap.servers·직렬화기 둘·acks·enable.idempotence)와 KafkaSecurityConfigurer.configure 가 만드는 성분 다섯(security.protocol·ssl.enabled.protocols·ssl.endpoint.identification.algorithm·sasl.mechanism·sasl.jaas.config)이 교집합 0 이라는 것으로 판정했다.
  • gradle.lockfile 은 읽지 않았다(STRUCTURAL_ONLY).

17. 손볼 것

17.1 P1 — 운영 배포에 TLS 와 인증을 선언하라고 요구한 뒤, 그 둘이 없는 생산자를 만든다

두 사실을 나란히 놓으면 보인다.

검증기가 요구한다. KafkaProfileValidator:

if (profile.production() && !profile.tlsEnabled()) {
  throw new IllegalArgumentException("a production Kafka connection requires TLS: " + profile.broker());
}
if (profile.production() && !profile.authenticationEnabled()) {
  throw new IllegalArgumentException("a production Kafka connection requires broker authentication: " + profile.broker());
}

그리고 이 리프의 kafkaProfileStartupValidation 이 그것을 설정에서 컴파일된 프로파일에도 실제로 돌린다. 전용 테스트가 있다 — aProductionKafkaBrokerWithoutTransportSecurityFailsStartup.

조립되는 생산자에는 그 둘이 없다. KafkaMessagingAutoConfiguration.messagingKafkaProducer:

Map<String, Object> config = new HashMap<>();
config.put(BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
config.put(KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
config.put(VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
config.put(ACKS_CONFIG, "all");
config.put(ENABLE_IDEMPOTENCE_CONFIG, true);
return new KafkaProducer<>(config);

다섯 항목이 전부다. security.protocol 이 없으므로 Kafka 클라이언트의 기본값 PLAINTEXT 로 접속한다.

그 둘을 만드는 코드는 있고, 아무도 부르지 않는다. KafkaSecurityConfigurer.configure(...) 가 정확히 다섯을 만든다.

properties.put(SECURITY_PROTOCOL, securityProtocol(profile, credential));   // SASL_SSL | SSL | SASL_PLAINTEXT | PLAINTEXT
if (profile.tlsEnabled()) {
  properties.put(ENABLED_PROTOCOLS, String.join(",", enabledProtocols));
  properties.put(ENDPOINT_IDENTIFICATION, "https");
}
 properties.put(SASL_MECHANISM, "SCRAM-SHA-512");
  properties.put(SASL_JAAS_CONFIG, scramJaas(scram.credentialId(), resolved));

이 클래스를 언급하는 production 코드는 저장소 전체에서 이 리프의 빈 선언 한 줄뿐이다. 나머지 참조는 자기 자신과 KafkaSecurityConfigurerTest 다.

그래서 배포가 겪는 것.

  1. app.messaging.brokers.k.production=true 를 쓴다.
  2. 검증기가 tls-enabled=trueauthentication-enabled=true 를 요구한다.
  3. 운영자가 둘을 켜고, app.messaging.security.k 에 SASL 자격 식별자를 적고, CredentialProvider 빈을 공급한다. 시작이 통과한다.
  4. 만들어진 생산자는 평문·무인증으로 접속한다.

세 검증(KafkaProfileValidator·MessagingCredentialRequirementValidator·BrokerTlsPolicy)이 전부 통과하고, 통과의 대상이 실제 연결이 아니다. 보안을 요구하지 않는 브로커에는 인증 없이 붙고, 요구하는 브로커에는 첫 발행에서 실패한다 — 어느 쪽도 "선언한 대로 접속했다" 가 아니다.

테스트가 이것을 볼 수 없는 이유. 조립을 확인하는 두 테스트(selectingKafkaAssemblesOnlyKafka·aSelectedTransportAssemblesAPublisher)는 빈의 존재만 단언한다. 유일한 실 브로커 시험 MessagingLiveRoundTripQualificationTest 는 보안 없는 KafkaContainerproduction=false 프로파일로 붙는다. 즉 이 플랫폼이 실제로 증명한 왕복은 평문 왕복 하나다.

수정. messagingKafkaProducerKafkaSecurityConfigurer 와 선택된 브로커의 BrokerSecurityProfile 을 받아 config.putAll(configurer.configure(profile, profile.producerCredential(), protocols, now)) 를 하는 것이다. 자격 회전이 목적이라면 생산자 하나를 고정 설정으로 만드는 형태 자체를 다시 봐야 한다 — KafkaSecurityConfigurer 의 javadoc 이 그 이유를 이미 적어 두었다.

"a client configured from a value read once at startup holds that value until the process restarts, so the rotation the credential store performs never reaches the broker connection."

지금 조립되는 생산자가 정확히 그 형태이고, 심지어 한 번 읽지도 않는다.

17.2 P2 — 같은 자동 설정 안에서 검증기 하나만 감싸이지 않는다

KafkaMessagingAutoConfiguration 은 검증기 셋을 만든다.

@Bean public KafkaProfileValidator kafkaProfileValidator() {  }
@Bean public StartupProfileValidation<KafkaBrokerProfile> kafkaProfileStartupValidation() {  }  // ← 감싼다
@Bean public KafkaTransactionProfileValidator kafkaTransactionProfileValidator() {  }
@Bean public KafkaPublishFailureClassifier kafkaPublishFailureClassifier() {  }

KafkaTransactionProfileValidator 에는 대응하는 StartupProfileValidation 이 없다. 즉 컨텍스트가 그 검증기를 발행하고 아무도 주입하지 않는다 — StartupProfileValidation 의 javadoc 이 서술한 이전 상태와 정확히 같은 형태다.

RabbitMessagingAutoConfiguration 은 검증기 하나이고 그것을 감싼다. 그러므로 이 가족에서 감싸이지 않은 검증기는 이 하나다.

트랜잭션 프로파일 검증이 무엇을 막는지는 그 클래스가 안다 — 비트랜잭션 생산자 위의 정확히 한 번 주장 같은 조합이다. 그 검증이 지금 돌지 않는다.

수정은 한 블록이다. 같은 파일의 kafkaProfileStartupValidation 형태를 복사해 세 번째 검증기를 감싼다.

17.3 P2 — 출고되는 신뢰성 체인 전체가 아무도 공급하지 않는 빈 뒤에 있고, 그 사슬이 자기 클래스 안을 가리킨다
@Bean @ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class})  public OutboxRelay outboxRelay()
@Bean @ConditionalOnBean(OutboxRelay.class)          public OutboxRelayWorker outboxRelayWorker()
@Bean @ConditionalOnBean(OutboxRelayWorker.class)    public MessagingOutboxRelayLifecycle outboxRelayLifecycle()
@Bean @ConditionalOnBean(OutboxRepository.class)     public OutboxCleanupJob outboxCleanupJob()
@Bean @ConditionalOnBean(InboxRepository.class)      public InboxCleanupJob inboxCleanupJob()
@Bean @ConditionalOnBean(IdempotentConsumer.class)   public TransactionalInboxHandler<Object> transactionalInboxHandler()

공급자가 없다. 여섯 빈 전부가 애플리케이션이 공급해야 하는 타입에 걸려 있다. 조건 자체는 옳고 근거도 정확하다 — 플랫폼이 기본 구현을 주면 조용히 엉뚱한 곳에, 또는 아무 데도 쓰지 않게 된다. 문제는 저장소 안에 그 타입을 공급하는 코드가 없다는 것이다. messaging-outbox-jdbc-postgresqlJdbcOutboxRepository 는 스프링 스테레오타입도 @Bean 선언도 없고, new JdbcOutboxRepository 가 main 에 0 건이다. 그래서 이 스타터를 켠 배포는 발행 경로는 얻고 발신함 경로는 얻지 못하며, 그 사실이 시작 시점에 어떤 신호도 내지 않는다.

사슬이 자기 클래스 안을 가리킨다. 둘째와 셋째가 같은 설정 클래스 안에서 방금 선언된 빈의 존재를 조건으로 삼는다. 스프링은 @ConditionalOnBean 을 자동 설정 클래스에서만, 그리고 등록 순서에 의존하는 방식으로만 신뢰할 수 있다고 문서화한다. 지금은 첫 조건이 이미 거짓이라 결과가 드러나지 않는다. 발신함을 배선하는 순간 이 사슬이 실제로 평가된다.

같은 가족의 다른 결정과 대비된다. 관리 평면은 스위치가 켜졌을 때 만들어지지 않는 타입의 부재를 javadoc 에 명시한다(DestructiveMessagingAdmin 하나). 이쪽은 여섯이 조용히 빠진다.

수정은 둘이다. 발신함을 요구하는 설정에서 저장소 빈이 없으면 시작을 거부하는 검증(이 가족의 StartupProfileValidation 형태), 그리고 중계·작업자·수명을 하나의 @Bean 으로 합치거나 조건을 전부 최초 두 타입으로 표현하는 것.

17.4 P3 — 죽은 매개변수 하나가 유일한 비기본값에서 NPE 를 낳는다
private static BrokerCredentialProfile credential(
    String broker, String role, BrokerSecuritySettings.Credential credential, Supplier<Boolean> required) {
  if (credential == null && Boolean.TRUE.equals(required.get())) {
    throw configurationError(key("security", broker, role), "a configured broker needs a %s credential; …");
  }
  String type = credential.type() == null ? "" : credential.type().toUpperCase(Locale.ROOT);

호출처가 셋이고 전부 () -> true 다.

credential(name, "producer", security.producer(), () -> true),
credential(name, "consumer", security.consumer(), () -> true),
Optional.ofNullable(security.admin()).map(admin -> credential(name, "admin", admin, () -> true))

그래서 이 매개변수는 값을 하나만 갖는다. 그리고 그것이 죽어 있다는 것보다 나쁜 성질이 있다 — 이 매개변수가 존재하는 이유("이 역할은 선택적이다")대로 () -> false 를 넘기면 credential == null 인 경로가 가드를 지나 다음 줄의 credential.type() 에서 NPE 로 죽는다. 즉 이 매개변수의 유일한 비기본값이 의도한 동작이 아니라 널 역참조다.

수정은 매개변수를 지우고 널 검사를 무조건으로 만드는 것이다. 선택적 역할이 필요해지는 날에는 Optional 을 돌려주는 별도 메서드가 그 자리다 — admin 이 이미 호출처에서 그렇게 다뤄진다.

17.5 P3 — 설정 경로의 재시도가 예외 분류를 표현할 수 없다

RetryPolicy 는 성분 열이고 그중 둘이 분류 집합이다.

Set<FailureCategory> retryableCategories,      // "categories added to the retryable set"
Set<FailureCategory> nonRetryableCategories,   // "categories removed from the retryable set"

DestinationSettings.Retry 에는 이 둘에 대응하는 키가 없고, 컴파일러가 상수로 채운다.

return new RetryPolicy(retry.mode(), retry.maxAttempts(), retry.initialDelay(), retry.maxDelay(),
    retry.multiplier(), retry.jitter(), retry.orderingImpact(),
    Set.of(), Set.of(),                                    // ← 설정으로 표현할 수 없다
    Optional.ofNullable(blankToNull(retry.destination())).map(DestinationName::new));

빈 집합은 "기본 분류 그대로" 라는 중립값이므로 오동작은 아니다. 문제는 비대칭이다. DestinationProfile 을 자바로 선언한 배포는 두 집합을 조정할 수 있고, 문서대로 YAML 로 설정한 배포는 할 수 없다. 이 리프가 반복해서 근거로 든 규칙이 정확히 그 비대칭을 금지한다.

"Which half went unchecked would depend on how the deployment happened to be written, which is the worst possible rule."

수정은 Retry 에 두 키를 더하는 것이다. FailureCategory 는 열거이므로 relaxed binding 이 그대로 처리한다.

17.6 P3 — 배치 발행자가 CompletionStage 를 돌려주면서 동기 예외를 던진다
public CompletionStage<BatchPublishResult> publish(List<PublishRequest<?>> requests, BatchPublishOptions options) {
  
  if (requests.size() > options.maxBatchSize()) {
    throw new MessageTooLargeException("BATCH_COUNT_EXCEEDED", );   // ← 스테이지가 아니라 던진다
  }

같은 클래스가 자기 의존 대상에 대해서는 정확히 이 형태를 방어한다.

} catch (RuntimeException synchronousFailure) {
  // A publisher that validates eagerly throws instead of returning a failed stage. Converting
  // it here keeps the "one result per index" contract that the caller resubmits from.

즉 "게으르게 검증하고 실패한 스테이지를 돌려준다" 가 이 클래스가 아는 계약인데, 자기 호출자에게는 그것을 지키지 않는다. 비동기 파이프라인으로 배치를 부르는 코드는 .exceptionally(...) 로 잡히지 않는 예외를 만난다.

등급이 P3 인 이유는 이것이 프로그래밍 오류(배치 크기 초과)이고 결과가 손실이 아니라 예외 형태의 불일치이기 때문이다. 전용 테스트(aBatchLargerThanItsLimitIsRefusedBeforeAnythingIsPublished)가 assertThatThrownBy 로 현재 동작을 고정하고 있으므로, 고치려면 그 테스트도 함께 바꾼다.

확인된 설계(문제 아님)
  • 하나의 뿌리가 마스터 조건을 소유하고 자식은 조건을 갖지 않는 것.
  • 제공자 선택을 클래스패스 사고가 아니라 닫힌 레지스트리의 속성으로 만든 것.
  • 등록과 조립 가능을 분리하고, 조립 못 하는 전송을 선택 단계에서 이유와 함께 거부한 것.
  • 꺼진 상태의 계약을 빈 이름과 살아 있는 스레드로 붙든 것 — 빈 목록만으로는 "꺼짐" 이 증명되지 않는다.
  • 시작 검증을 afterPropertiesSet 으로 돌린 것과 그 이유.
  • 프로파일을 두 출처에서 모으는 Supplier 를 쓴 것과 그 근거.
  • 모든 설정 거부가 타입이 아니라 키를 부르는 것.
  • 바인딩되지 않는 키를 record 성분에서 파생해 거부한 것 — 목록을 손으로 적으면 쓰는 날에만 맞는다.
  • 환경변수를 키 검증에서 제외하고 그 이유를 적은 것 — 밑줄 경계를 되돌릴 방법이 없고, 추측은 정상 배포를 거부한다.
  • 설정 참조 문서를 실행 가능한 진술로 만든 것.
  • 가짜 발행자가 조립 불가를 가린 사례를 테스트 주석에 남기고 가드 테스트를 붙인 것.
  • 저장소 기본 구현을 제공하지 않기로 한 판단과 그 근거.
  • 정리 작업은 스케줄러를 등록하지 않고 중계는 구동하는 비대칭과 각각의 이유.
  • 두 수명 주기의 phase 로 종료 순서를 표현한 것과 서로를 근거로 든 javadoc.
  • 배수 예산을 임차 기간으로 둔 것 — 그보다 오래 기다려도 증명되는 것이 없다.
  • MessageContracts 를 맨 Map 빈이 아니라 홀더로 만든 것 — 스프링에서 Map 은 중립적인 주입 타입이 아니다.
  • 메시지 계약 기본값을 빈 것으로 두어 fail-closed 로 만든 것.
  • actuator 끝점을 읽기 전용으로 두고 그것을 리플렉션으로 붙든 것.
  • JAAS 값 이스케이프와 제어문자 거부(KafkaSecurityConfigurer) — 지금은 아무도 부르지 않지만 코드 자체는 옳다.

Source anchors

src/messaging/messaging-spring-boot-starter/build.gradle:1-66
main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:1
main/java/…/autoconfigure/MessagingCoreAutoConfiguration.java:1-480
main/java/…/autoconfigure/MessagingConfigurationCompiler.java:1-331
main/java/…/autoconfigure/MessagingSettings.java:1-301
main/java/…/autoconfigure/DefaultBatchMessagePublisher.java:1-254
main/java/…/autoconfigure/MessagingConfigurationKeyValidator.java:1-227
main/java/…/autoconfigure/MessagingReliabilityAutoConfiguration.java:1-185
main/java/…/autoconfigure/DestinationSettings.java:1-177
main/java/…/autoconfigure/KafkaMessagingAutoConfiguration.java:1-170
main/java/…/autoconfigure/MessagingProviderSelection.java:1-150
main/java/…/autoconfigure/MessagingShutdownLifecycle.java:1-124
main/java/…/autoconfigure/RabbitMessagingAutoConfiguration.java:1-98
main/java/…/autoconfigure/MessagingCredentialRequirementValidator.java:1-92
main/java/…/autoconfigure/MessagingAdminAutoConfiguration.java:1-86
main/java/…/autoconfigure/MessagingPrefixMigrationValidator.java:1-83
main/java/…/autoconfigure/MessagingEndpoint.java:1-78
main/java/…/autoconfigure/PublishResults.java:1-71
main/java/…/autoconfigure/BrokerSettings.java:1-69
main/java/…/autoconfigure/MessagingOutboxRelayLifecycle.java:1-69
main/java/…/autoconfigure/MessagingAdminDurabilityValidator.java:1-68
main/java/…/autoconfigure/DefaultBlockingMessagePublisher.java:1-65
main/java/…/autoconfigure/ValidatedDestinationRegistry.java:1-59
main/java/…/autoconfigure/CompiledMessagingConfiguration.java:1-53
main/java/…/autoconfigure/BrokerSecuritySettings.java:1-50
main/java/…/autoconfigure/StartupProfileValidation.java:1-46
main/java/…/autoconfigure/DefaultReactiveMessagePublisher.java:1-39
main/java/…/autoconfigure/MessagingPlatformRootAutoConfiguration.java:1-36
main/java/…/autoconfigure/MessageContracts.java:1-35
main/java/…/autoconfigure/ReactiveMessagePublisher.java:1-32
test/java/…/autoconfigure/{MessagingAutoConfigurationTest:546, MessagingConfigurationBindingTest:320,
  BatchPublisherTest:316, MessagingStarterOffContractTest:254, MessagingLiveRoundTripQualificationTest:217,
  MessagingOutboxRelayLifecycleTest:203, BlockingFacadeTest:148, ReactiveFacadeTest:134,
  MessagingEndpointTest:133, MessagingShutdownLifecycleTest:78}
messaging-kafka/…/KafkaSecurityConfigurer.java:1-173      (§17.1 — 부르는 곳 없음)
messaging-kafka/…/KafkaProfileValidator.java:47-56        (§17.1 — 운영 TLS·인증 요구)
messaging-kafka/…/KafkaMessagingTransport.java:1-215      (§17.1 — 생산자를 감싸기만 한다)
messaging-policy/…/RetryPolicy.java:28-37                 (§17.5)
app-bootstrap/src/main/resources/application*.yml         (§12.1 — app.messaging.enabled 부재)
messaging-outbox-jdbc-postgresql/…/JdbcOutboxRepository.java  (§17.3 — 공급자 부재)

A19-MESSAGING-SPRING-CLOUD-STREAM-BRIDGE. messaging-spring-cloud-stream-bridge

분석 중에는 messaging/MESSAGING-SPRING-CLOUD-STREAM-BRIDGE.md 파일이었다. 635줄.

messaging-spring-cloud-stream-bridge 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-spring-cloud-stream-bridge SSOT owner: messaging-spring-cloud-stream-bridge integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-spring-cloud-stream-bridge
  • canonical state analysisFile: §A19-MESSAGING-SPRING-CLOUD-STREAM-BRIDGE
  • source path: src/messaging/messaging-spring-cloud-stream-bridge
  • registry allowed_dependencies: ["messaging-core-api", "messaging-policy", "messaging-transport-spi"]
  • registry runtime_memberships: [] — build-only
숫자
항목
production Java 파일 6
production LOC 507
패키지 1 (dev.caskeleton.messaging.streambridge)
test 파일 2
test 메서드(실행 확인) 20
선언된 의존 project 3 + vendor 1
실제 import되는 의존 project 2 (§12.4)

여섯 타입:

타입 종류 역할 leaf 밖 참조
MessagingBindingBridge interface 논리 목적지 ↔ Stream 바인딩 0
SpringCloudStreamPublisherBridge class 발행 측 + 위 인터페이스 구현 0
SpringCloudStreamConsumerBridge class 수신 측 0
StreamBridgePolicyGuard class 목적지가 브리지 대상인가 0
BindingProfileValidator class 바인딩 구성이 일관적인가 0
BindingCapabilityReport record 무엇을 보장하지 않는가 0
Coverage ledger
scope/file group count disposition reason
src/main/java/** (6) 6 FULL_READ 전 파일 본문 확인
src/test/java/** (2) 2 FULL_READ 20개 테스트명·단언 확인
build.gradle 1 FULL_READ 9줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

Spring Cloud Stream 바인딩을 이미 쓰는 서비스가 같은 논리 목적지에 닿게 하는 상호운용 seam이다.

// MessagingBindingBridge.java:8-15
 * <p>The bridge is an interoperability seam, not a second messaging API. Its whole reason to exist
 * is that a service already has Stream bindings and needs to reach the same destinations without a
 * rewrite.
 *
 * <p>Binder semantics are never promoted to platform guarantees. Stream's binder has its own retry,
 * its own dead-letter, and its own acknowledgement mode, and they look enough like the platform's
 * to be mistaken for them  so a destination that actually relies on the platform's versions is
 * refused by {@link StreamBridgePolicyGuard} rather than served with the binder's.

**"look enough like the platform's to be mistaken for them"**이 이 leaf 전체의 위협 모델이다. 브리지는 기능을 추가하지 않고 차이를 드러낸다.

세 층으로 그것을 한다.

무엇을
StreamBridgePolicyGuard 플랫폼 보장에 의존하는 목적지를 아예 거절
BindingProfileValidator 바인더 확장 속성이 프로파일 결정을 덮는 것을 거절
BindingCapabilityReport 남은 차이를 문장으로 기록

세 번째가 특이하다 — 거절할 수 없는 차이를 문서화 가능한 값으로 만든다.


2. 의존성과 런타임 배선

선언된 것과 쓰이는 것이 다르다.

선언 scope 실제 import
messaging-core-api api oDestinationName, MessagingConfigurationException, publish 6타입
messaging-policy api oDestinationProfile, RetryMode
messaging-transport-spi api x
org.springframework:spring-context implementation x

grep -rn 'import dev.caskeleton.messaging.transport\|import org.springframework' → exit 1.

Spring Cloud Stream 브리지가 Spring을 import하지 않는다. 바인더 접촉면 전체가 두 함수형 인터페이스로 추상화돼 있다 — SpringCloudStreamPublisherBridge.ChannelSendSpringCloudStreamConsumerBridge.BridgedHandler. javadoc이 그 목적을 적는다 — "isolated so the bridge is testable without a binder".

spring-context 의존은 실제 통합 코드가 있어야 필요했을 것인데 그 코드가 없다. §12.4.

나가는 것: 없다. 어떤 leaf의 allowed_dependencies에도 이 leaf가 없고 starter 목록에도 없다.

런타임 배선: 없음. runtime_memberships: []. bean 없음.

소비자 0 · membership [] · 조립 0의 삼중 정합messaging-kafka-share-experimental·messaging-schema-avro와 같은 상태다.


3. 패키지/컴포넌트 지도

게이트 (2단)
  StreamBridgePolicyGuard.validate(profile, enabled)
    ├── !enabled       → STREAM_BRIDGE_DISABLED
    ├── isOrdered()    → STREAM_BRIDGE_ORDERING_UNSUPPORTED
    ├── retry != NONE  → STREAM_BRIDGE_RETRY_UNSUPPORTED
    └── deadLetter on  → STREAM_BRIDGE_DLQ_UNSUPPORTED
          ↓ (통과 후)
  BindingProfileValidator.validate(profile, bindingName, extendedProperties, enabled)
    ├── guard.validate(...)                    ← 위임
    ├── 바인딩 이름 패턴                        → INVALID_BINDING_NAME
    ├── 충돌 확장 속성 8개                       → BINDING_OVERRIDES_PLATFORM_POLICY
    ├── profile.production()                    → BRIDGE_ON_PRODUCTION_DESTINATION
    └── → BindingCapabilityReport.bridged(...)  ← 네 보장 전부 false

발행
  SpringCloudStreamPublisherBridge(ChannelSend) implements MessagingBindingBridge
    ├── bindPublisher / bindConsumer  ← 두 맵
    └── publish(dest, payload, headers)
          ├── 바인딩 없음 → NO_OUTPUT_BINDING
          ├── send == true  → AMBIGUOUS  (STREAM_BRIDGE_NO_BROKER_EVIDENCE)
          └── send == false → REJECTED   (STREAM_BRIDGE_SEND_REFUSED)

수신
  SpringCloudStreamConsumerBridge                 ← MessagingBindingBridge를 구현하지 않음
    ├── register(dest, binding, BridgedHandler)
    └── dispatch(binding, payload, headers)
          ├── 미등록 → NO_BRIDGED_HANDLER
          └── handler.handle(...)  ← 예외를 잡지 않음

4. 계약·불변식·상태 모델

4.1 StreamBridgePolicyGuard — 의존하는 순간 거절
// :10-17
 * <p>The bridge exists for interoperability with existing Spring Cloud Stream bindings, and its
 * risk is specific: Stream owns its own binder configuration, so a binding can quietly acquire its
 * own serializer, its own error handling, and its own acknowledgement mode  none of which the
 * destination profile knows about.
 *
 * <p>So the bridge is only permitted where the platform's guarantees are not the thing being relied
 * on: a destination that declares an ordering scope, a retry policy, or a dead letter destination
 * must go through the native adapter, where those are actually enforced.

세 거절이 DestinationProfile의 세 필드를 직접 본다.

조건 코드
profile.isOrdered()orderingScope != NONE STREAM_BRIDGE_ORDERING_UNSUPPORTED
profile.retry().mode() != RetryMode.NONE STREAM_BRIDGE_RETRY_UNSUPPORTED
profile.deadLetter().enabled() STREAM_BRIDGE_DLQ_UNSUPPORTED

즉 **messaging-policy가 정의한 세 보장 각각에 대해 "이것을 선언했으면 브리지를 쓸 수 없다"**를 강제한다. 세 코드 전부 MessagingConfigurationException이고 안정 코드를 갖는다 — messaging-kafka-share-experimental이 두 거절에 다른 예외 타입을 쓴 것(그쪽 §17)과 대비된다.

!enabled도 같은 예외 타입이다 — 일관적이다.

4.2 BindingProfileValidator — 확장 속성을 병합하지 않는다
// :16-20
 * <p>The binder's extended properties are the sharp edge. Stream lets a binding override the
 * serializer, the acknowledgement mode, and the concurrency, and each of those silently replaces
 * something the destination profile already decided. Rather than merging the two  which produces a
 * configuration nobody can read  a conflicting extended property is rejected and the operator is
 * told which side to remove.

거절 목록 8개:

속성 무엇을 덮는가
autoBindDlq, republishToDlq DLQ 정책
maxAttempts, backOffInitialInterval 재시도 정책
autoCommitOffset, ackMode 정산
useNativeEncoding, contentType codec

에러 메시지가 두 선택지를 명시한다 — "remove it from the binding or move the destination to the native adapter". 무엇을 하라고만 하지 않고 어느 쪽을 포기할지를 준다.

production 목적지는 무조건 거절한다.

if (profile.production()) {
  throw new MessagingConfigurationException(
      "BRIDGE_ON_PRODUCTION_DESTINATION",
      "destination %s is marked production; the bridge does not carry the platform's publish "
          + "evidence, retry, or confirmed dead lettering");
}

guard의 세 조건을 통과한 목적지(순서 없음·재시도 없음·DLQ 없음)라도 production이면 막는다. 네 번째 게이트다.

바인딩 이름 패턴 [a-zA-Z][a-zA-Z0-9-]{0,63} — 언더스코어와 점을 배제한다.

4.3 BindingCapabilityReport — 부재를 값으로
// :8-14
 * <p>An explicit report rather than silence. The binder does provide retry and dead-lettering of
 * its own, so a binding looks like it has them; what it does not have is the platform's versions 
 * bounded attempts under the destination's retry policy, and a dead-letter publish confirmed before
 * the source is settled. An operator comparing a bridged binding to a native one needs that
 * difference written down, because nothing at runtime will show it.

**"nothing at runtime will show it"**이 이 record가 존재하는 이유다.

네 boolean과 두 factory:

factory 네 값
bridged(destination, bindingName) 전부 false
nativeAdapter(destination, bindingName) (BindingProfileValidator의 static) 전부 true

gaps()가 각 false마다 문장 하나를 만든다.

결여 문장
publish evidence "the binder reports a send, not a broker confirmation, so an ambiguous publish is indistinguishable from a confirmed one"
retry "the binder's own retry runs instead of the destination's retry policy, with its own attempt budget and backoff"
dead letter "the binder settles the source without waiting for the dead-letter publish to confirm, so a dead-letter outage loses the message"
ordering "the binder's concurrency settings decide ordering, not the profile"

각 문장이 결과까지 적는다 — "indistinguishable", "loses the message". 상태 플래그가 아니라 운영자가 읽는 진술이다.

isFullyGuaranteed()gaps().isEmpty()다 — 매 호출마다 네 문장을 다시 만든다. 성능 문제는 아니지만 순수 조회가 문자열을 할당한다.

4.4 SpringCloudStreamPublisherBridge — 가장 정직한 결과
// :20-27
 * <p>The result is deliberately {@code AMBIGUOUS} rather than {@code CONFIRMED}. A Stream {@code
 * send} returns a boolean from the message channel  it says the binder accepted the message, not
 * that a broker did. Reporting that as confirmed would put the platform's strongest word on the
 * binder's weakest evidence, and a caller reading {@code CONFIRMED} would stop worrying about a
 * message that may never have left the process.
 *
 * <p>A caller that needs real publish evidence has to use the native adapter. That is the honest
 * trade the bridge exists to make visible.

accepted == true일 때의 결과:

PublishCompletion.AMBIGUOUS,
new PublishEvidence(true, TransmissionEvidence.MAY_HAVE_BEEN_TRANSMITTED, false, ConfirmationLevel.NONE),
RoutingOutcome.UNKNOWN,
...
FailureDescriptor.of(FailureCategory.AMBIGUOUS, "STREAM_BRIDGE_NO_BROKER_EVIDENCE", ...)

messaging-core-apiPublishResult 14개 금지 조합을 전부 통과하도록 정확히 구성돼 있다AMBIGUOUSconfirmationLevel == NONE, brokerAccepted == false, transmission != NOT_TRANSMITTED, routingOutcome != ROUTED, failure.isPresent()를 요구하고 다섯 다 만족한다.

accepted == falseREJECTED + notTransmitted() + TRANSIENT_INFRASTRUCTURE — 채널이 거부했으므로 아무것도 나가지 않았고, 일시적 문제일 수 있으므로 재시도 가능하다.

두 결과가 core-api의 3상태를 정확히 쓴다. 이 저장소에서 AMBIGUOUS를 의도적으로 생성하는 몇 안 되는 지점이다.

Duration.ZERO를 elapsed로 넣는다 — 측정하지 않는다. PublishResult가 음수만 거절하므로 통과한다.

4.5 SpringCloudStreamConsumerBridge — 정산하지 않는다
// :12-18
 * <p>Settlement stays with the binder. The bridge cannot acknowledge, retry, or dead-letter a
 * message itself, because Stream's binder already owns the acknowledgement for that binding and two
 * things settling one message is worse than either doing it alone.
 *
 * <p>What the bridge does own is the translation and the honesty about it: a handler failure is
 * rethrown so the binder's error channel sees it, rather than being converted into a platform
 * {@code HandleResult} that nothing downstream would act on.

dispatch가 핸들러 예외를 잡지 않는다.

// Not caught. The binder's error channel is what retries and dead-letters this binding, and
// swallowing the failure here would acknowledge a message nothing handled.
handler.handle(destination, payload, headers);

HandleResult를 만들지 않는 것이 결정이다. javadoc이 "nothing downstream would act on"이라고 적는데, 이것은 messaging-runtime-coreDefaultDeliveryProcessor가 조립되지 않았다는 사실과 정합한다(§A19-MESSAGING-RUNTIME-CORE §12.1a) — 이 leaf가 그 사실을 알고 쓰였다.

ConcurrentHashMap(handlers, destinations)이 바인딩 이름을 키로 한다. 두 맵이 함께 갱신되지만 원자적이지 않다registerhandlers.putdestinations.put을 한다. 그 사이에 dispatch가 들어오면 handler는 있고 destination은 없어 NO_BRIDGED_HANDLER가 난다. 안전한 방향이다(잘못된 목적지로 전달하지 않는다). §17.

4.6 MessagingBindingBridge — 구현이 한쪽뿐

인터페이스가 bindPublisherbindConsumer 둘을 선언한다. SpringCloudStreamPublisherBridge가 둘 다 구현하고, SpringCloudStreamConsumerBridge는 이 인터페이스를 구현하지 않는다.

결과: bindConsumer가 publisher 쪽 inputBindings 맵에 기록되고, 실제 수신 등록(register)은 consumer 쪽에서 따로 일어난다. 두 클래스가 같은 바인딩에 대해 각자 상태를 갖는다. §17.


5. 주요 실행 경로

검증: validator.validate(profile, bindingName, extendedProperties, enabled) → guard 4검사 → 이름 → 속성 8개 → production → BindingCapabilityReport.bridged(...)

발행: bridge.bindPublisher(dest, binding)bridge.publish(dest, payload, headers)send.send(...) → true면 AMBIGUOUS, false면 REJECTED

수신: consumerBridge.register(dest, binding, handler) → 바인더가 dispatch(binding, payload, headers)handler.handle(...) (예외 그대로 전파)


6. 실패 경로와 복구/번역

코드 예외 위치
STREAM_BRIDGE_DISABLED MessagingConfigurationException guard
STREAM_BRIDGE_ORDERING_UNSUPPORTED 같음 guard
STREAM_BRIDGE_RETRY_UNSUPPORTED 같음 guard
STREAM_BRIDGE_DLQ_UNSUPPORTED 같음 guard
INVALID_BINDING_NAME 같음 validator
BINDING_OVERRIDES_PLATFORM_POLICY 같음 validator
BRIDGE_ON_PRODUCTION_DESTINATION 같음 validator
NO_OUTPUT_BINDING 같음 publisher bridge
NO_BRIDGED_HANDLER 같음 consumer bridge
STREAM_BRIDGE_NO_BROKER_EVIDENCE (예외 아님) PublishResult AMBIGUOUS publisher bridge
STREAM_BRIDGE_SEND_REFUSED (예외 아님) PublishResult REJECTED publisher bridge

아홉 개의 구성 실패가 전부 MessagingConfigurationException + 안정 코드다. 이 저장소 messaging family에서 예외 어휘가 가장 일관된 leaf다 — messaging-security(두 계층 혼용)·messaging-kafka-share-experimental(두 계층 혼용)·messaging-policy(검증기가 IllegalArgumentException)와 대비된다.

발행 결과 둘은 예외가 아니라 값이다 — messaging-core-api의 설계를 그대로 따른다.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

지점 도구
SpringCloudStreamPublisherBridge.outputBindings/inputBindings ConcurrentHashMap
SpringCloudStreamConsumerBridge.handlers/destinations ConcurrentHashMap

각 맵은 스레드 안전하지만 두 맵의 갱신이 원자적이지 않다(§4.5). 정산이나 자원 해제가 없으므로 다른 동시성 지점은 없다.

StreamBridgePolicyGuard·BindingProfileValidator는 상태가 없다(BindingProfileValidator가 guard 인스턴스를 필드로 하나 갖지만 그것도 무상태).

수명주기 참여 없음 — close()stop()이 없다. 등록된 핸들러를 해제하는 방법이 없다. §17.


8. 설정·기능 플래그·환경 차이

항목
프로퍼티 키(에러 메시지에만) backend.messaging.bridge.spring-cloud-stream
바인딩 이름 패턴 [a-zA-Z][a-zA-Z0-9-]{0,63}
충돌 확장 속성 8개

그 프로퍼티를 읽는 코드가 저장소에 없다. enabledvalidate(...)의 인자다. messaging-kafka-share-experimentalbackend.messaging.experimental.kafka-share와 같은 형태다(그쪽 §17).

상수 없음 — 두 패턴과 한 집합이 전부 private.


9. 퍼시스턴스/외부 시스템 세부

없다. Spring Cloud Stream 자체를 만지지 않는다 — 바인더 접촉면이 두 함수형 인터페이스(ChannelSend, BridgedHandler)로 추상화돼 있고 구현은 이 leaf 밖의 책임이다.

그래서 이 leaf는 바인더 없이 전부 테스트 가능하다 — 20개 테스트가 실제로 그렇게 한다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-spring-cloud-stream-bridge:test. BUILD SUCCESSFUL, 20 tests, 0 skipped, 0 failures.

클래스 무엇을 증명하는가
BindingProfileValidatorTest 10 허용 목적지, 비활성 거절, 순서/DLQ/production 거절, 충돌 속성 거절, 무해한 속성 통과, 이름 거절, 브리지 리포트가 네 결여를 전부 보고, native 리포트는 결여 없음
BridgePublishEvidenceTest 10 accepted → AMBIGUOUS, transmission unknown, descriptor가 결여를 이름, refused → REJECTED, 미바인딩 목적지 거절, payload 도달, 양방향 조회, 핸들러 실패가 바인더 error channel로 재던져짐, 미등록 바인딩 거절, 핸들러가 바인딩된 목적지를 받음

여섯 타입 전부가 테스트에 등장한다. 이 leaf는 messaging family에서 타입 대비 테스트 커버리지가 가장 고른 축이다 — messaging-kafka-share-experimental(4타입 중 1개만)·messaging-claim-check(publisher 미검증)·messaging-security(12 중 5개 미검증)와 대비된다.

aHarmlessBinderPropertyIsAllowedThrough가 특히 중요하다 — 거절 목록이 과잉 차단하지 않는다는 반대 방향 확인이다. messaging-core-api의 자격증명 세그먼트 매칭 테스트(aNameThatMerelyContainsTheLettersIsAccepted)와 같은 규율이다.

증명하지 않는 것: 실제 Spring Cloud Stream 바인더와의 통합. ChannelSend·BridgedHandler가 fake이므로 바인더가 실제로 이 계약대로 동작하는지는 이 레인 밖이다. 그리고 그 통합 코드 자체가 이 저장소에 없다(§12.1).


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies 세 project 의존 — 미사용 하나를 포함해 통과(허용 목록은 상한)
verifyRuntimeModuleMembership []
vendor api 규칙 Spring 타입이 public 시그니처에 없음 → implementation이 맞다. 다만 아예 쓰이지 않는다
SecretLeakStaticScanTest(observability leaf) 이 leaf 소스도 스캔 대상
ArchUnit 전용 규칙 없음

12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt.

12.1 Public surface reachability

여섯 타입 전부 leaf 밖 참조 0이다.

runtime_memberships: [], starter 미포함, 조립 0건 — 삼중 정합이다. incubating leaf가 이래야 하는 형태이고, messaging-claim-check·messaging-cloudevents가 어긋난 것과 대비된다.

다만 이 leaf는 미완의 성격이 다르다. 바인더 접촉면이 두 함수형 인터페이스로 추상화돼 있고 그 구현이 없다 — 즉 Spring Cloud Stream과 실제로 연결하는 코드가 존재하지 않는다. 이 leaf는 "브리지의 정책과 정직성"을 완성했고 "브리지 자체"는 없다.

그 사실이 spring-context 의존과 맞물린다(§12.4).

12.2 Conditional sibling comparison

Spring 주석 0개, bean 없음.

MessagingTransport 구현 sibling과의 비교:

leaf 브로커 접촉 membership
messaging-kafka·messaging-rabbit MessagingTransport 구현 ["app-bootstrap"]
messaging-pulsar-experimental·messaging-nats-experimental MessagingTransport 구현 []
messaging-kafka-share-experimental 부분 구현(TransportConsumerRegistration) []
이 leaf 구현 없음 — 자체 인터페이스 []

이 leaf는 MessagingTransport를 구현하지 않는 것이 의도다. 브리지는 transport가 아니라 다른 프레임워크로의 seam이고, 그래서 MessagingBindingBridge라는 자기 인터페이스를 갖는다. messaging-transport-spi 의존이 선언만 되고 쓰이지 않는 것이 그 판단과 정합한다 — 처음에 transport로 만들려다 방향을 바꾼 흔적으로 보인다(추론).

12.3 Duplicate mechanism sweep

(a) 활성화 플래그 패턴이 세 leaf에 있다

leaf 전달 방식
이 leaf backend.messaging.bridge.spring-cloud-stream validate(..., boolean enabled)
messaging-kafka-share-experimental backend.messaging.experimental.kafka-share KafkaShareProfile.enabled 필드
(pulsar·nats) 각 leaf SSOT가 답함

두 키 모두 에러 메시지에만 존재하고 읽는 코드가 없다. 같은 형태의 미완이다.

(b) capability 보고가 두 형태

위치 형태
messaging-core-api MessagingCapabilities boolean 12개, 브로커가 할 수 있는 것
이 leaf BindingCapabilityReport boolean 4개 + 문장, 브리지가 하지 않는 것

방향이 반대다. 전자는 능력 선언이고 후자는 결여 진술이다. 그리고 후자만 사람이 읽는 문장을 만든다. 중복이 아니라 서로 다른 질문에 답한다 — 다만 BindingCapabilityReport의 네 boolean이 MessagingCapabilities의 어느 필드와도 대응하지 않아, 두 모델을 잇는 코드가 생기면 매핑을 새로 정해야 한다.

(c) 순서·재시도·DLQ 거절이 여러 곳에

위치 무엇을 거절
messaging-policy DestinationProfileValidator 프로파일 내부 모순(순서 + 재정렬 재시도 등)
messaging-kafka-share-experimental KafkaShareProfileValidator 순서 목적지를 share group에
이 leaf StreamBridgePolicyGuard 순서·재시도·DLQ를 선언한 목적지를 브리지에

셋이 다른 질문에 답한다 — 내부 일관성 / 어댑터 능력 / seam 적격성. 중복 아니다. 다만 셋 다 DestinationProfile의 같은 필드를 읽고 서로를 참조하지 않는다.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
build.gradle: messaging-transport-spi 의존 import 0건 미사용 의존
build.gradle: spring-context 의존 org.springframework import 0건 미사용 의존
MessagingBindingBridge javadoc: "an interoperability seam" 바인더 연결 코드 없음 미실현
StreamBridgePolicyGuard 에러 메시지: backend.messaging.bridge.spring-cloud-stream=true 그 키를 읽는 코드 0건 미실현
BindingCapabilityReport javadoc: 운영자가 native와 비교할 수 있어야 함 nativeAdapter(...) 호출자가 테스트뿐 부분 미실현
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 실제로 [] 이 leaf에 한해 참

13. Git/설계 문서에서 확인한 변화와 실패 기록

이 leaf의 javadoc에 이전 결함 서술이 없다. 대신 막으려는 것을 다섯 적는다.

위치 막으려는 것
MessagingBindingBridge 바인더 의미론이 플랫폼 보장으로 승격되는 것
StreamBridgePolicyGuard 바인딩이 자기 serializer·error handling·ack mode를 조용히 획득하는 것
BindingProfileValidator 확장 속성과 프로파일을 병합해 "아무도 읽을 수 없는 구성"을 만드는 것
BindingCapabilityReport 차이를 침묵으로 두는 것 — "nothing at runtime will show it"
SpringCloudStreamPublisherBridge 바인더의 가장 약한 증거에 플랫폼의 가장 강한 단어를 붙이는 것
SpringCloudStreamConsumerBridge 두 주체가 한 메시지를 정산하는 것

여섯 파일 중 여섯이 "하지 않는 것"을 서술한다. 이 leaf는 기능이 아니라 경계로 구성돼 있다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-296 command evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt 여섯 타입 참조 0, membership [], 선언 의존 4개와 실제 import 목록, transport-spi·spring-context import 0(exit=1), 인터페이스 구현이 publisher뿐, nativeAdapter 호출자가 테스트뿐 정적 검색
EVD-297 command ./gradlew :messaging:messaging-spring-cloud-stream-bridge:test --rerun-tasks BUILD SUCCESSFUL, 20 / 0 / 0 바인더 없이 fake로 검증

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 브리지가 두 번째 messaging API가 아닌 이유 — MessagingBindingBridge javadoc
  • 바인더 의미론을 승격하지 않는 이유 — 같은 javadoc
  • 플랫폼 보장에 의존하는 목적지를 거절하는 이유 — StreamBridgePolicyGuard javadoc
  • 확장 속성을 병합하지 않고 거절하는 이유 — BindingProfileValidator javadoc
  • 결여를 명시적 리포트로 만드는 이유 — BindingCapabilityReport javadoc
  • AMBIGUOUS가 유일하게 정직한 답인 이유 — SpringCloudStreamPublisherBridge javadoc
  • 정산이 바인더에 남는 이유, 예외를 재던지는 이유 — SpringCloudStreamConsumerBridge javadoc
  • ChannelSend를 분리한 이유("testable without a binder") — 그 인터페이스 javadoc

추론

  • messaging-transport-spi 의존이 선언만 된 것은 처음에 transport로 만들려다 방향을 바꿨기 때문이다 → 추론. 의존 선언과 미사용은 관측이고 인과는 추론이다.
  • spring-context 의존이 선언만 된 것은 바인더 통합 코드를 상정했기 때문이다 → 추론.
  • SpringCloudStreamConsumerBridgeMessagingBindingBridge를 구현하지 않는 것이 의도인지 → 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 6개 타입 507줄 전문
  • 20개 테스트가 통과하고 여섯 타입 전부를 덮는다는 것
  • 여섯 타입 전부 참조 0이고 membership []과 정합한다는 것
  • messaging-transport-spispring-context가 선언되고 import 0건이라는 것
  • 바인더 접촉면이 두 함수형 인터페이스로 추상화돼 있고 그 구현이 저장소에 없다는 것
  • 아홉 구성 실패가 전부 같은 예외 타입과 안정 코드를 쓴다는 것
  • PublishResult가 core-api의 14개 금지 조합을 정확히 만족한다는 것

확인하지 못한 것

  • 실제 Spring Cloud Stream 바인더가 ChannelSend의 boolean 계약대로 동작하는지 — 바인더가 저장소에 없다.
  • backend.messaging.bridge.spring-cloud-stream 키가 어딘가 문서화돼 있는지.
  • SpringCloudStreamConsumerBridge에 해제 경로가 필요한지 — 바인더 수명주기를 모른다.
  • 이 leaf를 완성할 계획이 있는지.

17. 손볼 것

P3 — 선언된 의존 둘이 사용되지 않는다
  • 사실. registry가 messaging-transport-spi를 허용하고 build.gradlespring-context를 선언한다. main 소스의 비-JDK import 9개는 전부 messaging-core-apimessaging-policy에서 온다. import dev.caskeleton.messaging.transport · import org.springframework 검색이 exit 1이다.
  • 근거. evidence/raw/296 §B.
  • 왜 문제인가. verifyCleanArchitectureDependencies가 허용 목록을 상한으로 검사하므로 잡히지 않는다. 그리고 spring-context 선언이 "이 leaf가 Spring과 통합돼 있다"는 인상을 주는데 실제로는 Spring 타입을 한 번도 이름 부르지 않는다 — 바인더 접촉면 전체가 자체 함수형 인터페이스다.
  • 확인 방법. evidence/raw/296 §B 재실행.
  • 후보. 두 의존을 제거하거나, 완성 시 필요함을 build.gradle 주석에 적는다.
  • 다음 단계. messaging-kafka-share-experimental §17의 같은 항목과 동일 형태다. 두 incubating leaf가 같은 방식으로 미사용 의존을 선언한다 → REFERENCE 후보(허용 의존 목록은 상한이므로 미사용을 잡지 않는다).
P3 — 브리지의 바인더 쪽 절반이 없다
  • 사실. ChannelSend·BridgedHandler 두 함수형 인터페이스가 바인더 접촉면이고, 그 구현이 저장소에 없다. MessagingBindingBridge javadoc은 "a service already has Stream bindings and needs to reach the same destinations without a rewrite"를 존재 이유로 든다.
  • 근거. evidence/raw/296 §A·§B.
  • 왜 문제인가. 정책·검증·정직성 세 층이 완성돼 있고 그것들을 실제 바인딩에 연결하는 코드가 없다. runtime_memberships: []와 정합하므로 오늘의 결함은 아니지만, 이 leaf의 이름이 약속하는 것("spring-cloud-stream-bridge")이 절반만 존재한다.
  • 확인 방법. git grep -n 'ChannelSend\|BridgedHandler' -- src → 이 leaf와 그 테스트만.
  • 후보. 바인더 어댑터를 만들거나, 두 인터페이스가 파생 프로젝트의 구현점임을 javadoc에 명시한다.
  • 다음 단계. OPEN QUESTION 후보. messaging-kafka-share-experimental §17 첫 항목과 같은 질문("완성할 것인가")이다.
P3 — 인터페이스를 publisher만 구현하고 두 클래스가 같은 바인딩에 각자 상태를 갖는다
  • 사실. MessagingBindingBridgebindPublisher·bindConsumer 둘을 선언한다. SpringCloudStreamPublisherBridge가 둘 다 구현하고 inputBindings 맵에 기록한다. SpringCloudStreamConsumerBridge는 이 인터페이스를 구현하지 않고 자기 handlers·destinations 맵에 기록한다.
  • 근거. evidence/raw/296 §C.
  • 왜 문제인가. 한 바인딩에 대해 두 객체가 각자 등록을 갖고 서로를 모른다. bindConsumer를 부르고 register를 부르지 않으면 publisher 쪽은 바인딩이 있다고 보고하고 실제 전달은 NO_BRIDGED_HANDLER로 실패한다. consumerBinding(dest)가 그 불일치를 드러내지 않는다.
  • 확인 방법. 두 클래스의 필드와 인터페이스 구현 확인.
  • 후보. consumer bridge가 MessagingBindingBridge를 구현하고 publisher가 bindConsumer를 위임하거나, 인터페이스를 발행·수신으로 나눈다.
  • 다음 단계. REFERENCE 후보(한 개념의 등록 상태를 두 객체가 나눠 갖지 않는다).
P3 — 두 맵 갱신이 원자적이지 않다
  • 사실. SpringCloudStreamConsumerBridge.registerhandlers.put(...)destinations.put(...)을 한다. 같은 형태가 publisher의 두 맵에도 있다(다만 각각 독립 키).
  • 근거. SpringCloudStreamConsumerBridge.java:38-39.
  • 왜 문제인가. 그 사이에 dispatch가 들어오면 destination == null이 되어 NO_BRIDGED_HANDLER가 난다. 안전한 방향이다 — 잘못된 목적지로 전달하지 않는다. 다만 에러 코드가 "핸들러가 없다"인데 실제로는 핸들러가 있고 목적지가 아직 없다.
  • 확인 방법.put 사이의 창.
  • 후보. 한 record로 묶어 한 번에 put한다.
  • 다음 단계. REFERENCE 후보(함께 읽히는 두 맵은 한 값으로 묶는다).
P3 — 등록 해제 경로가 없다
  • 사실. SpringCloudStreamConsumerBridgeunregisterclose가 없다. SpringCloudStreamPublisherBridge도 마찬가지다.
  • 근거. 두 클래스의 public 메서드 전수.
  • 왜 문제인가. 바인딩이 재구성되거나 컨텍스트가 종료될 때 맵이 비워지지 않는다. 오늘은 조립되지 않아 무해하다. messaging-transport-spiTransportConsumerRegistrationAutoCloseable인 것과 대비된다.
  • 확인 방법. public 메서드 목록.
  • 후보. unregister(bindingName) 또는 AutoCloseable 구현.
  • 다음 단계. REFERENCE 후보(등록을 받는 컴포넌트는 해제도 제공한다).
P3 — 활성화 프로퍼티 키가 에러 메시지에만 존재한다
  • 사실. backend.messaging.bridge.spring-cloud-stream=trueSTREAM_BRIDGE_DISABLED 메시지에 적혀 있다. 그 키를 읽는 코드가 없다.
  • 근거. git grep -n 'spring-cloud-stream=true' -- src → 이 leaf의 문자열 하나.
  • 왜 문제인가. messaging-kafka-share-experimental·messaging-claim-check와 같은 형태다 — 메시지가 지시하는 설정에 대응 코드가 없다.
  • 다음 단계. 그 두 leaf의 같은 항목과 함께 REFERENCE 후보(에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다).
확인된 설계(문제 아님)
  • 플랫폼 보장에 의존하는 목적지를 브리지에서 아예 거절하는 4단 게이트
  • 확장 속성을 병합하지 않고 거절하며 어느 쪽을 지울지 알려 주는 것
  • 무해한 확장 속성은 통과시키고 그것을 테스트로 고정한 것
  • 결여를 boolean이 아니라 결과가 적힌 문장으로 만드는 것
  • 바인더의 boolean send를 AMBIGUOUS로 보고하고 그 이유를 적은 것
  • PublishResult가 core-api의 금지 조합을 정확히 만족하는 것
  • 정산을 바인더에 남기고 핸들러 예외를 재던지는 것
  • 바인더 접촉면을 함수형 인터페이스로 분리해 바인더 없이 전부 테스트 가능하게 한 것
  • 아홉 구성 실패가 한 예외 타입과 안정 코드를 쓰는 것
  • 소비자 0 · membership [] · 조립 0의 삼중 정합

Source anchors

id kind path revision what it proves limitations
MSB-001 registry src/config/architecture/modules.json 21234e38 deps 3개, runtime_memberships: [] 선언
MSB-002 build messaging-spring-cloud-stream-bridge/build.gradle same 네 의존 선언 둘은 미사용(§12.4)
MSB-003 code .../streambridge/StreamBridgePolicyGuard.java same §4.1 네 거절
MSB-004 code .../streambridge/BindingProfileValidator.java same §4.2 8속성 거절, production 거절
MSB-005 code .../streambridge/BindingCapabilityReport.java same §4.3 결여를 문장으로 nativeAdapter 호출자 테스트뿐
MSB-006 code .../streambridge/SpringCloudStreamPublisherBridge.java same §4.4 AMBIGUOUS 결정과 두 결과
MSB-007 code .../streambridge/SpringCloudStreamConsumerBridge.java same §4.5 정산 미소유, 예외 재던짐 두 맵 비원자(§17)
MSB-008 code .../streambridge/MessagingBindingBridge.java same seam 선언과 위협 모델 구현이 publisher뿐
MSB-009 test BindingProfileValidatorTest (10), BridgePublishEvidenceTest (10) same §10 표, 여섯 타입 전부 실제 바인더 없음
MSB-010 cross-leaf code messaging-core-api/.../PublishResult.java:39-101 same 두 결과가 만족하는 금지 조합 해당 leaf SSOT가 소유
MSB-011 cross-leaf code messaging-policy/.../DestinationProfile.java, RetryMode.java same 게이트가 읽는 세 필드 해당 leaf SSOT가 소유
EVD-296 command evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt same §12.1·§12.4 정적 검색
EVD-297 command ./gradlew :messaging:messaging-spring-cloud-stream-bridge:test --rerun-tasks same 20 / 0 / 0 fake 바인더

A19-MESSAGING-TESTKIT. messaging-testkit

분석 중에는 messaging/MESSAGING-TESTKIT.md 파일이었다. 1,039줄.

messaging-testkit 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-testkit SSOT owner: messaging-testkit integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-testkit
  • canonical state analysisFile: §A19-MESSAGING-TESTKIT
  • source path: src/messaging/messaging-testkit
  • registry allowed_dependencies: ["messaging-core-api", "messaging-schema-api", "messaging-policy", "messaging-transport-spi"]
  • registry runtime_memberships: [] — 런타임 배포 대상이 아니다. 그러나 §2에서 보듯 이 리프는 지금까지 본 [] 리프들과 성격이 다르다: 소비자가 4개 모듈, 12개 파일로 실재한다.
숫자
항목
Java 파일 총계 20
src/main 파일 13
src/test 파일 6
src/jmh 파일 1
전체 LOC (Java) 2,063
소스셋 3 (main / test / jmh) — messaging 리프 중 jmh 를 가진 3개 중 하나
비-Java 리소스 1 (src/main/resources/messaging/broker-certification-evidence.jsonl)
test 메서드(실행 확인) 44 (EVD-301)
선언된 의존 project 4 + vendor 2 (junit-jupiter, assertj) — 전부 api
leaf 밖에서 이 리프를 import 하는 파일 12 (4개 모듈)

src/main 13타입 — 이 리프에서 main 은 "프로덕션 코드"가 아니라 다른 리프의 test 컴파일 클래스패스에 올라가는 계약 자산이다.

타입 종류 역할 leaf 밖 참조
MessagingAdapterContract abstract class 모든 어댑터가 통과해야 할 7개 계약 테스트 2 (kafka, rabbit)
MessagingAdapterHarness interface 어댑터가 계약을 돌리기 위해 구현하는 seam 2
FaultController interface 주입 가능한 5개 결함 (하니스 경유) 2
ContractMessage record 고정 시험 메시지 (정상 / 1 MiB 초과) 2
ContractAssertions final class confirmed / ambiguous / rejected 단언 3종 2
ObservedDelivery record 배달 1건에 대해 하니스가 관측한 것 2
HandleOutcome enum 핸들러 결정 (SUCCESS/RETRY/DEAD_LETTER) 2
NetworkFaultScenario record + 5 상수 네트워크 결함 5종과 기대 결과 1 (kafka)
BrokerCertificationEvidence record 실제로 실행된 1건의 증거 (JSONL 1줄) 1 (kafka)
CertifiedEvidence final class 매니페스트 리더 1 (kafka)
BrokerFailureMatrix class 증거 → 커버리지 행렬 0
CompatibilityMatrix final class 어댑터별 지원 등급 0
DockerAvailability final class 컨테이너 런타임 가용성 4
Coverage ledger
scope/file group count disposition reason
src/main/java/** (13) 13 FULL_READ 전 파일 본문 확인
src/test/java/** (6) 6 FULL_READ 44개 테스트 메서드·단언 확인
src/jmh/java/** (1) 1 FULL_READ EnvelopeCodecBenchmark 126줄
src/main/resources/** (1) 1 FULL_READ 매니페스트 4줄 전문 (EVD-300)
build.gradle 1 FULL_READ 11줄
src/build.gradle jmh 블록 FULL_READ 루트 500-542줄 (§11)
build/** EXCLUDED 빌드 산출물 — 단 build/resources 매니페스트는 src 와 동일함을 diff 로 확인

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

이 리프는 "지원한다(supported)"라는 단어의 정의를 코드로 못 박는 곳이다. 플랫폼의 다른 어떤 리프도 "Kafka 는 Stable 이다" 를 주장하지 않는다. 그 주장은 여기에만 있고, 여기서만 검증된다.

세 개의 층으로 되어 있다.

  1. 공유 계약 (MessagingAdapterContract + MessagingAdapterHarness + ContractMessage/ContractAssertions/ObservedDelivery/HandleOutcome/FaultController) — 브로커가 무엇이든 똑같이 답해야 하는 7가지 행동.
  2. 결함 시나리오와 그 증거 (NetworkFaultScenario + BrokerCertificationEvidence + CertifiedEvidence + BrokerFailureMatrix) — 어떤 장애를 실제로 돌려 봤는가.
  3. 지원 등급 (CompatibilityMatrix) — 위 두 층의 결과로 어댑터가 얻는 등급.

경계는 명확하다. 이 리프는 어댑터를 구현하지 않고, 어댑터를 실행하지도 않는다. 하니스 구현은 각 어댑터 리프의 src/test 에 있다(KafkaContractHarness, RabbitContractHarness). 이 리프가 가진 유일한 하니스는 InMemoryMessagingHarness 이며 src/test 에 있고, 그 javadoc 이 스스로 선을 긋는다.

// InMemoryMessagingHarness.java:22-29
/**
 * A deterministic harness that exists only to prove the contract suite itself is sound.
 *
 * <p>It is not a production adapter and must never become one. Its value is that it has no broker
 * to hide behind: if a contract test passes here and fails on Kafka, the difference is in the
 * adapter, not in the test.
 */
final class InMemoryMessagingHarness implements MessagingAdapterHarness {

final + package-private + private 생성자 + 정적 팩토리. "프로덕션 어댑터가 되어서는 안 된다" 는 문장이 접근 제어자로도 강제되어 있다. src/main 이 아니라 src/test 에 둔 것도 같은 결정이다 — 다른 리프의 test 클래스패스에 올라가는 것은 src/main 뿐이므로, 이 하니스는 물리적으로 이 리프 밖으로 나갈 수 없다.


2. 의존성과 런타임 배선

// messaging-testkit/build.gradle 전문 (11줄)
apply plugin: 'java-library'

dependencies {
    api project(':messaging:messaging-core-api')
    api project(':messaging:messaging-schema-api')
    api project(':messaging:messaging-policy')
    api project(':messaging:messaging-transport-spi')

    api 'org.junit.jupiter:junit-jupiter'
    api 'org.assertj:assertj-core'
}

여섯 개가 전부 api 다. implementation 이 하나도 없다. 이것은 이 리프에서 옳은 선택이다: MessagingAdapterContract@Test자기 시그니처에 달고 있고(MessagingAdapterContract.java:28), ContractAssertions 는 AssertJ 를 반환 타입 없이 쓰지만 상속받는 쪽이 같은 AssertJ 를 봐야 하며, MessagingAdapterHarness.publishPublishResult(core-api)를, ContractMessageEncodedMessage(schema-api)를 공개 시그니처에 노출한다. 상속하는 쪽이 컴파일되려면 전부 전이되어야 한다.

runtime_memberships: [] 이지만 §12.1 의 판정은 다른 [] 리프들과 정반대다.

소비 모듈 build.gradle import 하는 파일
messaging-kafka testImplementation project(':messaging:messaging-testkit') 6
messaging-rabbit testImplementation … 3
messaging-outbox-jdbc-postgresql testImplementation … 2
messaging-inbox-jdbc-postgresql testImplementation … 1

추가로 messaging-kafka/build.gradle:91 이 이 리프의 리소스 파일 경로를 문자열로 참조한다(§4.3).

allowed_dependencies 4개 중 실제 import 여부:

선언 실제 사용 근거
messaging-core-api O MessageId, MessageEnvelope, PublishResult, PublishEvidence, FailureCategory, TraceContext, MessageHeaders, UuidV7
messaging-schema-api O EncodedMessage (ContractMessage.java:11)
messaging-policy X (§12.4) import dev.caskeleton.messaging.policy.* 0건
messaging-transport-spi X (§12.4) import dev.caskeleton.messaging.transport.* 0건

3. 패키지/컴포넌트 지도

단일 패키지 dev.caskeleton.messaging.testkit. 세 소스셋이 같은 패키지를 공유하므로 InMemoryMessagingHarness(test)가 FaultController(main)를 package-private 없이 구현할 수 있고, EnvelopeCodecBenchmark(jmh)도 같은 패키지에 있다.

데이터 흐름은 한 방향이다.

  [인증 레인 실행]                    (messaging-kafka: messagingCertificationTest)
        |  BrokerCertificationEvidence.toJson()  한 시나리오당 한 줄
        v
  broker-certification-evidence.jsonl          (messaging-testkit/src/main/resources)
        |  CertifiedEvidence.load()  — static 초기화 1회
        v
  List<BrokerCertificationEvidence>
        |  BrokerFailureMatrix.from(...)  — 기대치 대조 후 LIVE_BROKER 로 기록
        v
  BrokerFailureMatrix
        |  hasLiveBrokerCoverage(adapter)
        v
  CompatibilityMatrix.Entry.hasLiveBrokerCertification()   <- 선언이 아니라 파생
        |
        v
  docs/messaging/support-matrix.md 대조   (MessagingDocumentationContractTest)

핵심은 화살표 방향이 한 번도 역전되지 않는다는 것이다. 등급이 증거를 만들지 않고 증거가 등급을 만든다.


4. 계약·불변식·상태 모델

4.1 MessagingAdapterContract — 7개가 "지원한다"의 정의
// MessagingAdapterContract.java:10-19
/**
 * The behaviour every adapter must exhibit, regardless of broker.
 *
 * <p>This suite is the platform's actual definition of "supported". A broker is Stable when it
 * passes these unchanged — not when it has an adapter that compiles. …
 */
public abstract class MessagingAdapterContract {
  protected abstract MessagingAdapterHarness harness();

7개 테스트와 각각이 못 박는 것:

테스트 못 박는 불변식
publishesAndConfirms 확인은 CONFIRMED + brokerAccepted + confirmationLevel != NONE + TRANSMITTED 넷이 동시에
returnsAmbiguousWhenConfirmIsLost 확인 유실은 추측이 아니라 AMBIGUOUS + mayHaveBeenStored()
redeliversWhenSettlementIsLost 정산 미확인 → settled=false → 2회차 attempt=2, redelivered=true
preservesMessageIdAcrossRetryAndDlq 재시도·DLQ 를 거쳐도 MessageId 동일
keepsSourceUnsettledWhenDlqPublishFails DLQ 발행 실패 시 원본을 정산하지 않는다
rejectsOversizedPayloadBeforeTransport 크기 초과는 브로커가 아니라 로컬에서 거절
stopsAcceptingNewWorkDuringShutdown 종료 시작 후 발행은 로컬 거절

계약을 abstract class + @Test 로 만든 결정의 효과는 InMemoryHarnessContractTest 의 javadoc 에 있다.

// InMemoryHarnessContractTest.java:5-11
/**
 * Runs the shared adapter contract against the in-memory harness.
 *
 * <p>This is the contract testing itself. Every broker adapter adds the same nested class over its
 * own harness, so a guarantee can only be weakened by editing the contract, where the change is
 * visible, rather than by an adapter quietly not implementing it.
 */

즉 어댑터가 @Test삭제하는 방법이 없다. 상속받는 순간 7개가 전부 실행된다. 어댑터 쪽에서 하나를 빼려면 이 파일을 고쳐야 하고, 그것은 리뷰에 보인다.

그리고 그 7개가 침묵으로 줄어드는 것을 막는 자물쇠가 하나 더 있다.

// CompatibilityMatrixTest.java:13-21, 71-80
private static final List<String> REQUIRED_CONTRACT_TESTS = List.of(
    "publishesAndConfirms", "returnsAmbiguousWhenConfirmIsLost", "redeliversWhenSettlementIsLost",
    "preservesMessageIdAcrossRetryAndDlq", "keepsSourceUnsettledWhenDlqPublishFails",
    "rejectsOversizedPayloadBeforeTransport", "stopsAcceptingNewWorkDuringShutdown");

List<String> declared = Arrays.stream(MessagingAdapterContract.class.getDeclaredMethods())
    .filter(method -> method.isAnnotationPresent(org.junit.jupiter.api.Test.class))
    .map(Method::getName).toList();
assertThat(declared).containsExactlyInAnyOrderElementsOf(REQUIRED_CONTRACT_TESTS);

리플렉션으로 @Test 가 붙은 메서드 이름 집합을 상수와 정확히 대조한다. 계약에서 테스트 하나를 지우면 이 테스트가 깨진다. 추가해도 깨진다. 계약의 크기 자체가 잠겨 있다.

4.2 NetworkFaultScenario — 기대 결과를 시나리오가 소유한다
// NetworkFaultScenario.java:7-17
/**
 * <p>The expected outcome is part of the scenario rather than left to each test, because the whole
 * value of a fault suite is that every adapter answers the same way. A scenario that let each
 * adapter declare its own expectation would pass while the adapters disagreed — which is exactly
 * the situation the shared contract exists to catch.
 *
 * <p>The distinction across these scenarios is what evidence survives. …
 */

5개 시나리오, 그리고 각각이 rationale비어 있으면 생성 자체가 실패하도록 강제한다.

// NetworkFaultScenario.java:59-62
if (rationale == null || rationale.isBlank()) {
  throw new IllegalArgumentException(
      "a scenario without a rationale cannot be reviewed; state why this outcome is correct");
}
시나리오 Phase Expectation rationale 요지
connection-refused BEFORE_TRANSMISSION REJECTED 바이트가 안 나갔으니 브로커가 가질 수 없다
connection-cut-after-write AFTER_TRANSMISSION AMBIGUOUS 여기서 거절이라 답하는 게 "잃어버린 확인 하나를 주문 둘로" 만든다
confirm-timeout AFTER_TRANSMISSION AMBIGUOUS "타임아웃은 부재의 증거가 아니라 증거의 부재"
settlement-lost DURING_SETTLEMENT REDELIVERED 재배달은 설계이며, 핸들러가 멱등이어야지 플랫폼이 정산된 척하면 안 된다
high-latency AFTER_TRANSMISSION AMBIGUOUS 결정 시점에 지연과 유실은 구별 불가

REJECTEDBEFORE_TRANSMISSION 하나뿐이라는 사실이 테스트로 잠겨 있다(CrossBrokerContractSuite.aFailureBeforeTransmissionIsTheOnlyOneReportedAsRejected).

byName 은 알 수 없는 이름을 건너뛰지 않고 거절한다.

// NetworkFaultScenario.java:125-136
.orElseThrow(() -> new IllegalArgumentException(
    "no fault scenario is named '" + name + "'; evidence for a scenario this release does not"
    + " define is coverage of nothing"));
4.3 CertifiedEvidence / BrokerCertificationEvidence — 증거는 실행이 쓴다

이 리프에서 가장 밀도 높은 설계다. 두 javadoc 이 자기가 고친 결함을 이름 붙여 남겼다.

// CertifiedEvidence.java:9-27
/**
 * The fault scenarios this repository's certification lane has actually exercised.
 *
 * <p>Read from a manifest a run produced, not from a list somebody typed. It was the list: an
 * author added a line and the compatibility matrix, the cross-broker suite and the support matrix
 * all believed it, on a machine that may never have started a container. "Certified against a live
 * broker" then meant "a developer wrote that down", which is the one thing a certification claim
 * must not mean.
 *
 * <p>The manifest is committed because a release claim has to survive the build that produced it —
 * a matrix that reads {@code build/} answers differently on a clean checkout. What stops it from
 * being hand-written again is the gate: {@code verifyMessagingCertificationEvidence} runs the lane
 * against a real broker and refuses to pass when this file claims a scenario the run did not
 * produce. …
 *
 * <p>An absent or empty manifest is not an error here. It means no scenario has evidence, so every
 * scenario is {@link BrokerFailureMatrix.Coverage#NOT_COVERED} and every Stable claim that depends
 * on evidence fails — which is the correct reading of "nothing has been run".
 */

네 가지 결정이 한 문단에 압축되어 있고, 넷 다 코드에서 확인된다.

(a) 커밋한다. src/main/resources/messaging/broker-certification-evidence.jsonl. build/ 를 읽으면 깨끗한 체크아웃에서 답이 달라진다는 이유가 명시되어 있다. 확인: srcbuild/resources 사본이 diff 로 동일(EVD-300).

(b) 손으로 못 쓰게 하는 게이트. messaging-kafka/build.gradle:82verifyMessagingCertificationEvidence.

// messaging-kafka/build.gradle (doLast 발췌)
Closure<Set<String>> claims = { File file ->
    file.readLines('UTF-8').findAll { !it.trim().isEmpty() }
        .collect { line -> line.replaceAll(/,"gitCommit":"[^"]*"/, '')
                               .replaceAll(/,"observedAt":"[^"]*"/, '') }.toSet()
}
Set<String> ran = claims(produced.get().asFile)
Set<String> shipped = claims(committed)
if (ran != shipped) {  "claimed but not produced: …"  }

gitCommitobservedAt 을 정규식으로 지우고 나머지 집합을 비교한다. 그 둘은 매 실행마다 달라지므로 비교 대상이 아니라는 주석이 붙어 있다. outputs.upToDateWhen { false } — "이전 실행 결과를 재사용할 수 있는 게이트는 그 이전 실행에 대한 증거일 뿐" 이라는 주석과 함께.

(c) 부재는 오류가 아니고, 읽기 실패는 오류다.

// CertifiedEvidence.java:66-83
if (manifest == null) { return List.of(); }

} catch (IOException failure) {
  // Not swallowed into "no evidence": a manifest that exists and cannot be read is a broken
  // build, and reporting it as an absence would quietly demote every adapter instead.
  throw new IllegalStateException();
}

없음 → 빈 목록(모두 NOT_COVERED). 있는데 못 읽음 → 예외. 이 구분이 "조용한 강등"을 막는다.

(d) 직렬화 형식이 스스로를 보호한다. JSON 라이브러리를 쓰지 않는 이유가 적혀 있다("this module is the test support for a platform whose *-api leaves carry no dependencies at all"). 대신 손으로 쓴 파서가 세 가지를 거절한다.

// BrokerCertificationEvidence.java:96-105
for (String field : FIELDS) {                    // 7개 필드 중 하나라도 없으면 거절
  if (!fields.containsKey(field)) { throw new IllegalArgumentException(
      "certification evidence is missing '" + field + "': " + json); } }
if (fields.size() != FIELDS.length) {            // 모르는 필드가 있어도 거절
  throw new IllegalArgumentException(
      "certification evidence carries fields this release does not define: " + fields.keySet()); }

그리고 쓰기 쪽에서 이스케이프가 필요한 값을 아예 막는다.

// BrokerCertificationEvidence.java:47-55
// The serialised form is a flat object written by hand, so a quote or a backslash in any field
// would produce a manifest line that the reader silently truncates instead of rejecting. …
requireUnescaped(adapter, "adapter");  requireUnescaped(gitCommit, "gitCommit");

"조용히 잘라 읽는 대신 거절한다" 가 이 파일 전체를 관통하는 규칙이다. 이 규칙은 테스트로도 잠겨 있다(CertifiedEvidenceTest.aValueThatWouldBreakTheManifestIsRefusedWhenTheEvidenceIsBuilt).

한 줄 = 한 객체(JSONL)인 이유도 명시적이다: "appending a scenario is an append and a diff of two runs is a diff of two sets of lines"(BrokerCertificationEvidence.java:22-24).

4.4 BrokerFailureMatrix.requireOutcomeMatchesExpectation — 틀린 증거는 증거가 아니다

이 리프에서 가장 날카로운 단 하나의 판단이다.

// BrokerFailureMatrix.java:129-146
/**
 * Refuses evidence whose observed outcome contradicts the scenario it claims to cover.
 *
 * <p>Nothing compared the two, so a run that reported a definite rejection where the scenario
 * says the outcome is unknowable counted as coverage of that scenario. That is worse than no
 * evidence: it certifies the adapter for exactly the behaviour it got wrong, and the support
 * matrix then tells an adopter the case is handled.
 */
private static void requireOutcomeMatchesExpectation(BrokerCertificationEvidence observed) {
  String expected = observed.scenario().expectation().name();
  if (!expected.equals(observed.outcome())) { throw new IllegalArgumentException(); }
}

"틀린 증거는 무증거보다 나쁘다" — 실패한 인증을 커버리지로 세면, 어댑터가 정확히 틀린 그 행동에 대해 인증된다. 이 판단이 테스트로 재현되어 있고(CrossBrokerContractSuite.evidenceThatDisagreesWithItsScenarioIsNotCoverage), 그 테스트가 쓰는 반례가 실재하는 Rabbit 테스트 이름이라는 점이 좋다.

// CrossBrokerContractSuite.java:58-72
new BrokerCertificationEvidence("messaging-rabbit", "rabbitmq:4.3-management",
    NetworkFaultScenario.CONNECTION_CUT_AFTER_WRITE,
    "RabbitBrokerIT#anUnroutablePublishIsRejectedEvenThoughTheExchangeConfirmedIt",
    "REJECTED", "repository-local", );
assertThatThrownBy(() -> BrokerFailureMatrix.from(List.of(contradicting)))
  .as("certifying an adapter for the behaviour it got wrong is worse than no evidence")
  .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("AMBIGUOUS");
4.5 CompatibilityMatrix — 파생된 인증, 선언된 나머지
// CompatibilityMatrix.java:50-62
/**
 * Reports whether a lane has run this adapter against a real broker.
 *
 * <p>Read from the evidence rather than declared. As a field it was a boolean an author set
 * next to the tier, and RabbitMQ carried {@code true} while no fault scenario had ever been
 * executed against it — the same shape as the coverage matrix that used to assert its own
 * constants.
 */
public boolean hasLiveBrokerCertification() {
  return BrokerFailureMatrix.from(CertifiedEvidence.recorded()).hasLiveBrokerCoverage(adapter);
}

Entry 는 5개 필드 중 4개가 선언값이고 1개(hasLiveBrokerCertification)만 파생값이다. 그리고 그 하나가 정확히 거짓말이 실제로 발생했던 자리다.

ENTRIES 맵의 주석 두 개가 자기 이력을 남긴다.

// CompatibilityMatrix.java:79-92
"messaging-kafka",
    // 4.1 only. The declared 4.2/4.3 were never run: the fixtures pin apache/kafka:4.1.0 and
    // the locked client is 4.1.1, so the matrix and the support document agreed with each
    // other and with nothing that had executed. A version returns here when a lane runs
    // against its image.
    new Entry("messaging-kafka", List.of("4.1"), Tier.STABLE, true, true),
"messaging-rabbit",
    // Experimental until a lane runs a fault scenario against a RabbitMQ container. The
    // adapter passes the shared contract and its broker IT exercises the routable and
    // unroutable happy paths, but Stable in this repository means fault evidence, and
    // there is none: connection-refused, cut-after-write, confirm-timeout,
    // settlement-lost and high-latency have never been executed here.
    new Entry("messaging-rabbit", List.of("4.3"), Tier.EXPERIMENTAL, true, false),

(rabbit 의 인자는 runsSharedContract=true, enabledByDefault=false — 계약은 통과하지만 기본 비활성.)

"문서와 행렬이 서로 동의했고, 실행된 것과는 아무것도 동의하지 않았다" 는 문장이 이 리프 전체의 요약이다.

runsSharedContract 는 여전히 선언 불리언이지만 §12.1 에서 두 Stable/준-Stable 어댑터에 대해 실측 검증했다: kafka·rabbit 둘 다 실제로 extends MessagingAdapterContract 를 한다. 나머지 셋은 false 로 선언되어 있고 실제로도 상속하지 않는다. 현재는 일치한다.

4.6 ContractMessage — 고정 시험 데이터
// ContractMessage.java:17-26
/**
 * <p>Fixed rather than random so that a failure in one adapter can be compared byte for byte
 * against another. The oversized variant exists to prove the payload limit is enforced locally: an
 * adapter that lets it reach the broker turns a deterministic rejection into a broker-specific
 * error.
 */

FIXED_TIME = 2026-08-10T09:15:00Z 고정, oversized()new byte[1_048_577] = 1 MiB + 1 바이트. 이 값은 PayloadPolicy.DEFAULT_MAX_BYTES = 1_048_576 과 정확히 맞는다 — 그러나 상수를 참조하지 않고 리터럴을 다시 쓴다(§12.3).


5. 주요 실행 경로

경로 A — 어댑터 계약 실행 (컨테이너 불필요, 항상 실행)

InMemoryHarnessContractTest.Contract  (또는 KafkaProducerContractTest.Contract 등)
  -> MessagingAdapterContract.<7개 @Test>
       -> harness() 로 새 하니스 1개
       -> harness.faults().drop…() 로 결함 주입 (3/5 만 사용, §12.1)
       -> harness.publish(ContractMessage.…)
       -> harness.drain(HandleOutcome.…)
       -> ContractAssertions.assert…(result)
       -> try-with-resources 로 close()

MessagingAdapterHarness extends AutoCloseable 이고 close() 가 checked exception 을 던지지 않도록 재선언되어 있다(MessagingAdapterHarness.java:71-72). 7개 테스트 전부 try (…) 로 감싸므로 하니스 누수 경로가 없다.

경로 B — 인증 증거 생산 (컨테이너 필요, test 에서 제외)

:messaging:messaging-kafka:messagingCertificationTest   (tag = 'messaging-certification')
  -> KafkaBrokerCertificationIT  (toxiproxy 로 결함 주입)
  -> BrokerCertificationEvidence.toJson() 한 줄씩
  -> build/messaging-certification/broker-certification-evidence.jsonl
:messaging:messaging-kafka:verifyMessagingCertificationEvidence
  -> 생산물 vs 커밋본 집합 비교 (gitCommit/observedAt 제외)

경로 C — 등급 판정 (컨테이너 불필요, 매 빌드)

CertifiedEvidence.<clinit>  -> 클래스패스 리소스 1회 로드
CompatibilityMatrixTest / CrossBrokerContractSuite / MessagingDocumentationContractTest
  -> BrokerFailureMatrix.from(recorded()) -> 등급 단언 -> docs/messaging/*.md 대조

경로 C 가 경로 B 없이도 돌고, 경로 B 가 없으면 매니페스트가 비어 등급 주장이 무너진다는 것이 설계의 핵심이다.


6. 실패 경로와 복구/번역

이 리프의 실패 처리 원칙은 하나다: 모르는 것을 아는 척하지 않는다.

상황 처리 근거
매니페스트 없음 빈 목록 → 전부 NOT_COVERED CertifiedEvidence.java:68-70 + javadoc 25-27
매니페스트 읽기 실패 IllegalStateException CertifiedEvidence.java:77-82
필드 누락 IllegalArgumentException("missing '…'") BrokerCertificationEvidence.java:96-101
모르는 필드 존재 IllegalArgumentException("fields this release does not define") :102-105
JSON 아님 / 잘림 IllegalArgumentException :117-118, 131-133
모르는 시나리오 이름 IllegalArgumentException("coverage of nothing") NetworkFaultScenario.java:129-135
결과가 기대와 불일치 IllegalArgumentException — 커버리지로 세지 않음 BrokerFailureMatrix.java:137-145
등록되지 않은 어댑터 조회 IllegalArgumentException("not in the compatibility matrix") CompatibilityMatrix.java:126-132
기록 없는 어댑터의 커버리지 조회 NOT_COVERED (예외 아님) BrokerFailureMatrix.java:60-64

마지막 두 줄의 구분이 의도적이다. CompatibilityMatrix.of("messaging-artemis") 는 던지고(anUnknownAdapterIsNotSilentlyTreatedAsSupported), matrix.coverageOf("messaging-artemis", …)NOT_COVERED 를 돌려준다(aFaultThatWasNeverRecordedReadsAsUncoveredRatherThanPassing). 전자는 "지원 목록에 없는 것을 지원인 척"을 막고, 후자는 "기록 없음"이 곧 "커버 안 됨"이라는 자연스러운 읽기다.

DockerAvailability 는 반대 방향의 실패 처리다.

// DockerAvailability.java:3-9
/**
 * <p>Live-broker suites are guarded on this rather than assumed. A developer machine or a build
 * agent without Docker should skip them with a stated reason, not fail with a connection error that
 * looks like a product defect — and the release gate checks separately that the suites actually ran
 * where they were supposed to.
 */

Class.forName("org.testcontainers.DockerClientFactory") 를 리플렉션으로 부르고 어떤 예외든 false 로 삼킨다(:26-34). 이 리프가 testcontainers 에 의존하지 않으면서 그 존재를 물어볼 수 있게 하는 유일한 방법이고, 결과를 static final 로 1회만 캐시한다.

주목할 점: "skip 은 성공이 아니다" 라는 반대 규칙이 인증 레인에는 적용되어 있다.

// messaging-kafka/build.gradle
// The certification tag is excluded from `test` because the lane deliberately carries no Docker
// guard. Every other container suite here skips with a stated reason when Docker is absent; a lane
// that skipped would report success for a broker nobody started, which is the whole failure the
// evidence exists to rule out. Keeping it out of `test` is what lets it fail closed without
// breaking a laptop build.

일반 컨테이너 스위트는 DockerAvailability 로 skip 하고, 인증 레인만 가드 없이 실패한다. 대신 test 태그에서 빼서 노트북 빌드를 깨지 않는다. 두 규칙이 충돌하지 않게 배치되어 있다.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음. 동시성 관련해서 세 가지를 확인했다.

CertifiedEvidence.RECORDEDstatic final 이며 클래스 초기화 시 1회 로드된다(:34). JVM 클래스 초기화 락이 스레드 안전을 보장하고, 반환되는 ListStream.toList() 결과라 불변이다. 테스트가 병렬로 돌아도 안전하다.

BrokerFailureMatrix 는 가변이고 동기화가 없다(LinkedHashMap, :23). 그러나 from(...) 이 매번 새 인스턴스를 만들고 그 안에서만 record(...) 를 호출한 뒤 반환하므로, 실제 사용 패턴에서 공유되는 인스턴스가 없다. CrossBrokerContractSuite 는 필드 하나(:34)를 갖지만 JUnit5 기본 생명주기가 메서드당 인스턴스라 매 테스트가 자기 행렬을 만든다. public BrokerFailureMatrix record(...) 가 노출되어 있어 원리상 외부에서 공유·변형할 수 있으나, 실제 그런 호출부는 0건이다(git grepmatrix.recordfrom(...) 내부 1건).

InMemoryMessagingHarness 는 전부 비동기화 컬렉션(ArrayDeque, ArrayList, LinkedHashSet)이고 CompletableFuture.completedFuture(...) 로 즉시 완료한다. 결정론이 목적이므로 옳다 — 실제 스레드 전환이 하나도 없다.

수명주기는 MessagingAdapterHarness 의 두 메서드에 압축되어 있다.

// MessagingAdapterHarness.java:61-72
/** Begins a graceful drain. */
void beginShutdown();
boolean isAcceptingWork();
@Override void close();

stopsAcceptingNewWorkDuringShutdownbeginShutdown()isAcceptingWork()==false 와 발행 로컬 거절 둘 다를 요구한다. 플래그만 바꾸고 발행을 계속 받는 구현은 통과하지 못한다.


8. 설정·기능 플래그·환경 차이

이 리프 자체에는 설정이 없다. 환경 차이는 전부 소비 측 Gradle 에 있다.

스위치 위치 기본값 의미
-PmessagingKafkaImage messaging-kafka/build.gradle apache/kafka:4.1.0 인증 대상 브로커 이미지
-PmessagingToxiproxyImage ghcr.io/shopify/toxiproxy:2.12.0 결함 주입 프록시
messaging.certification.manifest 〃 (systemProperty) build/messaging-certification/…jsonl 레인이 쓸 경로
messaging.certification.commit -PcertificationCommitGITHUB_SHAlocal 증거에 박히는 커밋
태그 messaging-certification test 에서 제외 노트북 빌드 보호

이미지를 고정한 이유가 주석에 있다: "A certification claim names the build it was made against, so a floating tag would make a red run unattributable and a green one unrepeatable." 그리고 observedAt·gitCommit 을 게이트 비교에서 제외하는 이유도 함께 있다. 커밋을 -P 가 아니라 환경변수로 읽는 이유까지 적혀 있다("so the CI job's command line stays the literal grammar the gate matrix lint accepts").

CertifiedEvidenceTest.everyClaimNamesTheImageItRanAgainst:latest 를 금지한다 — "a floating tag makes a certification unrepeatable".


9. 퍼시스턴스/외부 시스템 세부

DB 없음. 외부 시스템 접점은 두 개다.

클래스패스 리소스 1개. /messaging/broker-certification-evidence.jsonl. 현재 4줄이며 전부 messaging-kafka 다(EVD-300).

adapter scenario outcome testId
messaging-kafka confirm-timeout AMBIGUOUS KafkaBrokerCertificationIT#aConfirmThatNeverArrivesIsReportedAsAmbiguousAndTheRecordIsThere
messaging-kafka connection-cut-after-write AMBIGUOUS KafkaBrokerCertificationIT#aConnectionCutAfterTheWriteIsReportedAsAmbiguous
messaging-kafka high-latency AMBIGUOUS KafkaBrokerCertificationIT#latencyBeyondThePublishDeadlineIsReportedAsAmbiguous
messaging-kafka settlement-lost REDELIVERED KafkaBrokerCertificationIT#aDeliveryLostBeforeSettlementIsRedelivered

브로커 이미지는 4줄 모두 apache/kafka:4.1.0, gitCommit 은 4줄 모두 e98b56eb….

모집단 5 시나리오 × 5 어댑터 = 25 칸 중 4칸에 증거가 있다. connection-refused 는 어느 어댑터에도 증거가 없고, messaging-rabbit 은 5칸 전부 비어 있다.

Docker 데몬. DockerAvailability 가 리플렉션으로만 접촉한다.


10. 테스트 레인과 실제 증명 범위

EVD-301: ./gradlew :messaging:messaging-testkit:test --rerun-tasks44 tests, 0 failures, 0 errors, 0 skipped.

클래스 증명 대상
CompatibilityMatrixTest 11 등급 규칙, 계약 크기 잠금, 미등록 어댑터 거절
CrossBrokerContractSuite 10 증거→커버리지 변환, 기대 불일치 거절, 시나리오 불변식
CertifiedEvidenceTest 8 매니페스트 원본성, 이미지/테스트ID 형식, gap 명명, 직렬화 왕복·거절
MessagingDocumentationContractTest 8 docs/messaging/*.md 9개 존재·내용·등급 일치
InMemoryHarnessContractTest$Contract 7 공유 계약 7개

이 레인이 증명하는 것과 증명하지 않는 것의 경계가 이 리프의 핵심이다.

증명한다: 매니페스트를 읽는 코드가 옳다. 등급이 매니페스트에서 파생된다. 문서가 등급과 일치한다. 계약이 7개다. 계약 7개가 결정론적 하니스에서 통과한다.

증명하지 않는다: 매니페스트에 든 4줄이 진짜 실행에서 나왔다는 것. 그것은 messaging-kafka:verifyMessagingCertificationEvidence 만 증명하고, 그 레인은 test 에서 제외되어 있으며 Docker 를 요구한다. 이 세션에서 실행하지 않았다(§16).

MessagingDocumentationContractTest 의 자기 제한이 좋다.

// MessagingDocumentationContractTest.java:18-20
 * <p>The assertions are deliberately narrow: they check the claims a reader would act on, not
 * prose. Asserting on wording would make every edit a test failure and the check would be deleted.

문서 검사가 삭제당하지 않도록 검사 범위를 스스로 좁혔다. 그리고 noEnumConstantTheDocsDenyActuallyExists 는 방향이 반대다 — 문서가 "없다"고 한 것(EXACTLY_ONCE, GLOBAL)이 실제로 enum 에 없는지를 확인한다. 문서의 부정 주장을 코드로 검증하는 것은 드문 패턴이다.


11. 빌드/ArchUnit/CI 강제 지점

jmh 소스셋이 루트에서 정확히 3개 리프에만 부여된다.

// src/build.gradle:500-508
// The three messaging leaves that carry JMH benchmarks get a `jmh` source set. It is a source
// set rather than a plugin because the benchmarks are compiled and reviewed on every build but
// only *run* on demand: a benchmark that stops compiling is a defect, while a benchmark that
// runs in CI is a flaky test measuring the build agent.
if (project.path in [':messaging:messaging-kafka',
                     ':messaging:messaging-rabbit',
                     ':messaging:messaging-testkit']) {

그 아래에서 spotbugsJmhcheckstyleJmh끈다.

// src/build.gradle:528-535
// JMH's annotation processor emits the generated harness into this source set, and its
// generated code trips DLS_DEAD_LOCAL_STORE by design (the dead stores are how it defeats
// dead-code elimination). … The benchmarks themselves are still compiled, which is what
// catches a real breakage.
tasks.named('spotbugsJmh') { enabled = false }
tasks.named('checkstyleJmh') { enabled = false }

마지막 문장("still compiled")이 참인지가 갈림길이다. 그 두 태스크가 check → compileJmhJava 로 가는 유일한 경로이기 때문이다. 실측했다(EVD-298):

./gradlew :messaging:messaging-testkit:build --dry-run
  :messaging:messaging-testkit:compileJmhJava SKIPPED
  :messaging:messaging-testkit:jmhClasses SKIPPED
  :messaging:messaging-testkit:checkstyleJmh SKIPPED
  :messaging:messaging-testkit:spotbugsJmh SKIPPED

참이다. Gradle 의 enabled = false 는 태스크 액션만 건너뛰고 의존성 그래프는 유지하므로, 꺼진 checkstyleJmh/spotbugsJmh 가 여전히 compileJmhJava 를 끌고 들어온다. 벤치마크는 매 빌드에서 컴파일되고 실행만 온디맨드다. 반직관적이라 증거로 남겼다.

compileJmhJava 에서 ErrorProne 을 끄고 -Werror 를 제거하는 이유도 명시적이다: "ErrorProne's -Werror would reject JMH's generated sources, which the platform does not own and cannot fix."

jmh 태스크는 JavaExecorg.openjdk.jmh.Main 을 부른다(:536-541).

EnvelopeCodecBenchmark 가 무엇을 재는지에 대한 판단도 적혀 있다.

// EnvelopeCodecBenchmark.java:31-41
/**
 * Measures the per-message cost the platform adds before any broker is involved.
 *
 * <p>This is the number the platform is accountable for. Broker latency dominates any real publish
 * and varies with the network, so measuring it would tell you about the test environment; …
 *
 * <p>Header validation is benchmarked separately from envelope construction because they scale
 * differently: construction is constant, while validation is linear in the header count …
 */

4개 벤치마크: generateMessageId(UuidV7), validateFewHeaders(3개), validateManyHeaders(32개), buildEnvelope. 헤더 맵을 @Setup 에서 미리 만들어 "맵 생성이 아니라 검증을 잰다"는 것을 보장한다.


12. 실제 사용 여부와 negative-space probes

12.1 Public surface reachability

방법 주의. 참조 계수는 단어 검색이 아니라 import dev.caskeleton.messaging.testkit 및 타입별 git grep 으로 셌다. 이 리프의 타입 이름(CompatibilityMatrix, BrokerFailureMatrix 등)은 저장소 내 동명 클래스가 없어 충돌은 없었으나, isComplete/reset 같은 메서드 이름은 충돌이 심하다git grep "isComplete" 는 10건을 내지만 9건이 fileserver/websocket 의 무관한 클래스다(EVD-299, EVD-300). 메서드 단위 판정은 전부 소유 타입을 확인한 뒤 세었다.

타입/멤버 leaf 밖 참조 판정
MessagingAdapterContract 2 (kafka, rabbit extends) 사용됨
MessagingAdapterHarness 2 (implements) 사용됨
ContractMessage / ContractAssertions / ObservedDelivery / HandleOutcome 2씩 사용됨
DockerAvailability 4 모듈 사용됨
NetworkFaultScenario, BrokerCertificationEvidence 1 (kafka 인증 IT) 사용됨
CertifiedEvidence 1 사용됨
CompatibilityMatrix 0 (leaf 내부 테스트만) leaf-local
BrokerFailureMatrix 0 (leaf 내부 테스트만) leaf-local
FaultController.rejectPublish() 호출 0건 §17 P2
FaultController.reset() 호출 0건 §17 P2
BrokerFailureMatrix.adapters() 호출 0건 §17 P3
BrokerFailureMatrix.isComplete(...) 호출 1건, Experimental 에만 §12.4

CompatibilityMatrix/BrokerFailureMatrix 가 leaf 밖 참조 0인 것은 결함이 아니다. 이 둘의 소비자는 문서와 릴리스 판정이고, 그 판정은 이 리프의 테스트에서 이뤄지도록 설계되어 있다.

FaultController 의 두 미사용 메서드는 다르다(EVD-299).

dropPublishConfirmation()   : 호출 1건 (MessagingAdapterContract:40)
dropSettlementConfirmation(): 호출 1건 (MessagingAdapterContract:53)
failDeadLetterPublish()     : 호출 1건 (MessagingAdapterContract:104)
rejectPublish()             : 호출 0건
reset()                     : 호출 0건

인터페이스 5개 중 3개만 계약이 쓴다. 나머지 2개는 구현이 3벌 강제되면서 아무도 부르지 않는다.

12.2 Conditional sibling comparison

같은 저장소에 "증거 기반 등급" 을 하는 형제가 하나 더 있다.

src/adapter/outbound/persistence-mongo/src/test/java/.../performance/MongoReleaseEvidenceTest.java
  MongoFailoverScenario.all().forEach(scenario -> gate.record(scenario, true));

Mongo 쪽은 gate.record(scenario, true) 를 테스트가 직접 호출한다 — 즉 테스트가 증거를 선언한다. messaging 쪽은 매니페스트 파일이 증거를 나르고 테스트는 읽기만 한다. CertifiedEvidence 의 javadoc 이 고쳤다고 말하는 바로 그 형태가 Mongo 쪽에는 아직 남아 있다. 이는 이 리프의 결함이 아니라 같은 교훈이 아직 전파되지 않은 곳이며, family 문서에서 다룰 대비다.

messaging 내부에서 DockerAvailability 를 쓰는 4개 모듈과 인증 레인의 관계도 대비된다: 전자는 없으면 skip, 후자는 가드 없이 실패 — §6 에 근거 인용.

12.3 Duplicate mechanism sweep

(a) Faults 내부클래스 3중복 — 바이트 동일. (EVD-299)

KafkaContractHarness.java    : 57줄  sha256[0:16]=3028b4591144fe86
RabbitContractHarness.java   : 57줄  sha256[0:16]=3028b4591144fe86
InMemoryMessagingHarness.java: 57줄  sha256[0:16]=3028b4591144fe86
diff kafka vs rabbit   -> IDENTICAL
diff kafka vs inmemory -> IDENTICAL

private static final class Faults implements FaultController 57줄이 3개 모듈에 완전히 동일하게 존재한다. 총 171줄. 4개 불리언 필드 + 5개 오버라이드 + 4개 consume/query 메서드. 이 리프의 src/mainDefaultFaultController 하나만 두면 3벌이 1벌이 된다. 세 하니스가 faults 필드 타입만 공유하면 되므로 API 변경도 필요 없다.

(b) 1 MiB 한도 리터럴 8중복. PayloadPolicy.DEFAULT_MAX_BYTES = 1_048_576 이 있는데도 같은 값이 리터럴로 다시 선언된다.

messaging-policy/PayloadPolicy.java:17                     DEFAULT_MAX_BYTES = 1_048_576   <- 정본
messaging-schema-api/RawBytesMessageCodec.java:21          DEFAULT_MAX_BYTES = 1_048_576
messaging-schema-json/JacksonMessageCodec.java:42          DEFAULT_MAX_BYTES = 1_048_576
messaging-schema-avro/AvroMessageCodec.java:46             DEFAULT_MAX_BYTES = 1_048_576
messaging-schema-protobuf/ProtobufMessageCodec.java:35     DEFAULT_MAX_BYTES = 1_048_576
messaging-claim-check/…RetentionValidatorTest.java:47      PORTABLE_PAYLOAD_LIMIT_BYTES
messaging-rabbit/RabbitContractHarness.java:40             MAX_PAYLOAD_BYTES
messaging-testkit/InMemoryMessagingHarness.java:31         MAX_PAYLOAD_BYTES     <- 이 리프
messaging-testkit/ContractMessage.java:50                  new byte[1_048_577]   <- 이 리프
messaging-spring-boot-starter/DestinationSettings.java:175 @DefaultValue("1048576")

messaging-testkitapi project(':messaging:messaging-policy') 를 선언하고 있으므로 PayloadPolicy.DEFAULT_MAX_BYTES 를 그냥 참조할 수 있다. §12.4 의 "policy 미사용" 과 합치면, 유일하게 policy 를 써야 할 자리에서 쓰지 않고 있는 셈이다.

(c) hasLiveBrokerCertification 대조 테스트의 항등식.

// CompatibilityMatrixTest.java:48-60  aCertificationClaimCannotBeMadeWithoutEvidence
assertThat(entry.hasLiveBrokerCertification())
    .isEqualTo(BrokerFailureMatrix.from(CertifiedEvidence.recorded()).hasLiveBrokerCoverage(entry.adapter()));

Entry.hasLiveBrokerCertification() 의 본문이 정확히 우변과 같다(CompatibilityMatrix.java:60-62). 이 단언은 항상 참인 항등식이며, 어떤 회귀도 잡지 못한다. 같은 파일의 everyStableAdapterIsCertifiedAgainstALiveBroker(:97-109)와 noExperimentalAdapterClaimsLiveBrokerCertification(:111-116)이 실질 검사를 하고 있어 커버리지 손실은 없지만, 이름이 약속하는 것("증거 없이 인증 주장 불가")을 이 테스트 자체는 검사하지 않는다.

12.4 Documentation / measured-count drift

(a) BrokerFailureMatrix 클래스 javadoc 이 강제되지 않는 규칙을 선언한다. (EVD-300)

// BrokerFailureMatrix.java:18-20
 * <p>A Stable adapter must cover every scenario. That rule is enforced by a test rather than
 * documented, because a promotion to Stable is exactly the moment the gap would otherwise be
 * overlooked.

측정:

git grep -n "isComplete" -- src   (messaging-testkit 범위)
  BrokerFailureMatrix.java:95     public boolean isComplete(String adapter) {
  CrossBrokerContractSuite.java:110  assertThat(matrix.isComplete("messaging-pulsar-experimental"))

isComplete 의 호출부는 1곳이고 그것은 Experimental 어댑터가 불완전함을 단언한다. Stable 어댑터에 isComplete 를 거는 테스트는 없다.

그리고 실제로 Stable 인 messaging-kafka 는 gap 을 가진 채 통과한다 — 그 사실이 같은 모듈에서 명시적으로 단언되어 있다.

// CertifiedEvidenceTest.java:52-55
assertThat(CertifiedEvidence.knownGaps("messaging-kafka"))
    .as("a Kafka producer buffers before it learns a connection exists, so this stays unproven")
    .contains(NetworkFaultScenario.CONNECTION_REFUSED);

코드는 "정직한 gap 열거"로 바뀌었고 그 결정이 테스트 본문 주석에 남아 있다.

// CrossBrokerContractSuite.java:44-47
void everyStableAdapterCoversEveryFaultScenario() {
  // The gaps are named rather than asserted empty. A Stable adapter with unrun scenarios is the
  // current, honest state; asserting emptiness here would only reinstate the self-declaration.

바뀌지 않은 것은 두 가지다: BrokerFailureMatrix 의 클래스 javadoc 과, 저 테스트 메서드 이름(everyStableAdapterCoversEveryFaultScenario — 본문은 covers 를 검사하지 않는다). 이 리프의 나머지 javadoc 들이 자기 이력을 정확히 갱신해 온 것과 대비되어 눈에 띈다.

(b) 선언된 project 의존 4개 중 2개가 import 0건.

messaging-core-api      -> 사용 O
messaging-schema-api    -> 사용 O  (EncodedMessage)
messaging-policy        -> import 0건
messaging-transport-spi -> import 0건

messaging-spring-cloud-stream-bridge, messaging-kafka-share-experimental 에서 이미 본 것과 같은 형태다. 다만 여기는 §12.3(b) 때문에 성격이 다르다 — policy 를 안 쓰는 게 아니라 써야 하는데 리터럴로 우회하고 있다.

(c) 증거의 커밋이 현재 트리가 아니다.

현재 HEAD               : 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
매니페스트의 gitCommit  : e98b56eb03ecab588c21fd1e7dbcaa493c1d8645  (히스토리에 존재)

이는 결함이 아니다. 게이트가 gitCommit/observedAt 을 비교에서 제외하는 것이 명시적 설계이며 그 이유가 주석에 있다. 다만 BrokerCertificationEvidence javadoc 이 "the commit are here because 'certified' is a claim about … a specific source tree; without them the evidence cannot be checked against anything later" 라고 쓴 것에 비해, 실제로 그 필드를 읽어서 무언가를 판정하는 코드는 없다. 기록은 되고 활용은 되지 않는다.

(d) 지원 문서 9개 존재·내용 검사는 통과. MessagingDocumentationContractTest 8건 전부 통과(EVD-301). 단, 이 검사는 docs/messaging/support-matrix.md등급 표기만 본다. 같은 문서 23줄의 runtime_memberships 관련 서술 드리프트는 이 검사의 사정권 밖이며 §A19 에서 다룬다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

a24ece9c feat: web, websocket 어댑터 추가 구현
01372634 refactor: 각 어댑터터별 리펙토링 진행
2f5d2fc2 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
d646c2f1 feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가

커밋 메시지는 정보가 거의 없다. 그러나 이 리프는 코드 주석이 커밋 로그를 대신하는 드문 사례다. 세 개의 javadoc 이 각각 "무엇이 틀렸었고 왜 지금 형태인가" 를 남겼다.

위치 기록된 과거 결함
BrokerCertificationEvidence.java:11-15 "Coverage used to be a constant: every scenario marked live-broker for both brokers, with the suite that 'verified' the matrix asserting the contents of that same map."
CertifiedEvidence.java:12-16 "It was the list: an author added a line and the compatibility matrix, the cross-broker suite and the support matrix all believed it, on a machine that may never have started a container."
CompatibilityMatrix.Entry.hasLiveBrokerCertification javadoc :52-57 "As a field it was a boolean an author set next to the tier, and RabbitMQ carried true while no fault scenario had ever been executed against it."
BrokerFailureMatrix.from javadoc :111-114 "A hard-coded matrix marked every scenario as live-broker coverage for both brokers, and the suite that checked it asserted the same constants, so the whole claim passed on a machine with no Docker."
CompatibilityMatrix ENTRIES 주석 :80-84 "the matrix and the support document agreed with each other and with nothing that had executed."
CrossBrokerContractSuite 필드 javadoc :22-32 "A hard-coded map … would leave the assertions below comparing that map to itself"

여섯 곳이 같은 결함의 여섯 얼굴이다: 자기 자신을 검증하는 상수. 그리고 여섯 곳 모두 지금은 매니페스트를 가리킨다.

messaging-rabbit 이 Stable 에서 Experimental 로 강등된 흔적도 남아 있다: CompatibilityMatrixTest.theStableSetIsExactlyWhatALaneHasCertified.as("RabbitMQ passes the shared contract, but no fault scenario has been run against it"). 강등의 근거가 "계약은 통과하지만 결함 증거가 없다" 로 정확히 적혀 있다.


14. 런타임·터미널 Evidence

ID 파일 내용
EVD-298 evidence/raw/298-testkit-jmh-compile-graph.txt build --dry-run 태스크 그래프 — compileJmhJava 포함 확인, 꺼진 태스크가 의존성을 유지함
EVD-299 evidence/raw/299-faultcontroller-dead-fault-triplicate.txt rejectPublish/reset 호출 0건, Faults 57줄 3중복 sha256 동일
EVD-300 evidence/raw/300-certification-manifest-and-stable-rule-drift.txt 매니페스트 4줄 전문, 5시나리오 모집단, isComplete 호출 1건(Experimental 전용), javadoc 드리프트
EVD-301 evidence/raw/301-messaging-testkit-test-lane.txt 테스트 레인 44건 전건 통과

15. 명시적 설계 이유와 추론을 구분한 정리

코드/주석에 명시된 것

  • 지원 행렬을 문서가 아니라 코드로 들고 테스트를 거는 이유 — "a support matrix that lives only in prose drifts the first time an adapter is added" (CompatibilityMatrix.java:10-13).
  • 인증 주장을 등급 옆 불리언이 아니라 증거에서 파생하는 이유 — "so an adapter cannot be promoted by editing a boolean" (:15-16).
  • 매니페스트를 커밋하는 이유 — 깨끗한 체크아웃에서 답이 달라지면 안 된다 (CertifiedEvidence.java:17-19).
  • 빈 매니페스트가 오류가 아닌 이유 / 읽기 실패가 오류인 이유 (:25-27, :78-79).
  • JSONL 한 줄 = 한 객체인 이유 — append 가 append 이고 diff 가 diff 이도록 (BrokerCertificationEvidence.java:22-24).
  • JSON 라이브러리를 안 쓰는 이유 — *-api 리프가 의존을 안 갖는 플랫폼의 테스트 지원이므로 (:83-86).
  • 기대 결과를 시나리오가 소유하는 이유 — 어댑터별 기대치는 불일치를 통과시킨다 (NetworkFaultScenario.java:10-13).
  • 틀린 증거를 거절하는 이유 — 틀린 그 행동에 대해 인증하게 된다 (BrokerFailureMatrix.java:131-135).
  • 인메모리 하니스가 프로덕션이 되면 안 되는 이유 (InMemoryMessagingHarness.java:24-27).
  • 문서 검사를 좁게 하는 이유 — 넓으면 삭제당한다 (MessagingDocumentationContractTest.java:18-20).
  • jmh 를 플러그인이 아니라 소스셋으로 두는 이유 — 컴파일은 매 빌드, 실행은 온디맨드 (src/build.gradle:501-504).
  • 인증 레인에 Docker 가드를 두지 않는 이유 — skip 이 성공으로 보고되면 안 된다 (messaging-kafka/build.gradle).
  • 이미지를 고정하는 이유 / 커밋을 환경변수로 읽는 이유 (messaging-kafka/build.gradle strictTestLanes 주석).
  • 계약을 abstract class 로 두는 이유 — 어댑터가 조용히 빼지 못하도록 (InMemoryHarnessContractTest.java:8-10).
  • 고정 시험 데이터의 이유 — 어댑터 간 바이트 비교 (ContractMessage.java:19-23).
  • DockerAvailability 가드의 이유 — 없는 Docker 가 제품 결함처럼 보이면 안 된다 (DockerAvailability.java:5-9).

추론 (근거는 있으나 문서에 없음)

  • 여섯 개 의존이 전부 api 인 것은 계약 상속 요건 때문이다 — @Test, AssertJ, PublishResult, EncodedMessage 가 전부 공개 시그니처에 노출된다. 주석은 없다.
  • CompatibilityMatrix/BrokerFailureMatrix 의 leaf 밖 참조 0은 설계된 경계로 보인다(소비자가 문서 검사와 릴리스 판정이므로). 명시된 문장은 없다.
  • messaging-policy/messaging-transport-spi 의존이 남아 있는 이유는 알 수 없다. 이전에 쓰였다가 빠졌을 가능성이 있으나 git 로그 입도가 커밋 4개뿐이라 확인 불가.
  • reset()/rejectPublish() 가 계약에 없는 이유는 알 수 없다. 5개 시나리오 중 connection-refused(=REJECTED)만 증거가 없다는 사실과 rejectPublish 가 미사용이라는 사실이 같은 빈칸을 가리키는 것은 우연으로 보이지 않으나, 이를 잇는 문장은 코드에 없다.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • src/main 13, src/test 6, src/jmh 1 — 20개 Java 파일 전부 본문 확인.
  • 테스트 레인 44건 전건 통과 (EVD-301).
  • build 그래프에 compileJmhJava 포함 (EVD-298).
  • 매니페스트 4줄 전문과 시나리오 모집단 5개 (EVD-300).
  • rejectPublish/reset 호출 0건, Faults 3중복 sha256 동일 (EVD-299).
  • kafka·rabbit 이 실제로 extends MessagingAdapterContract 함 — runsSharedContract 선언값과 일치.
  • isComplete 호출부 1건이며 Stable 을 검사하지 않음.

확인하지 못한 것

  • 매니페스트 4줄이 실제 실행에서 나왔는지해소됨 (EVD-318). messaging-kafka 분석 시 ./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence 를 실행했다. 레인이 실제 apache/kafka:4.1.0 + toxiproxy 컨테이너에 대해 돌아 4줄을 생산했고, 게이트가 커밋본과 비교해 통과했다 — adapter·brokerImage·scenario·testId·outcome 이 4줄 모두 일치한다. 커밋된 매니페스트는 이 revision 에서 레인이 실제로 생산하는 것과 같다.
  • 게이트가 실제로 도는지해소됨 (EVD-318). verifyMessagingCertificationEvidenceBUILD SUCCESSFUL 로 완료되고 build/reports/messaging-certification-evidence.txtscenarios=4 를 남겼다.
  • verifyMessagingCertificationEvidence실패하는 것은 확인하지 않았다. 손으로 매니페스트에 한 줄을 더하고 레인을 다시 도는 것은 애플리케이션 소스 수정에 해당해 하지 않았다.
  • jmh 태스크 실제 실행 결과 — 벤치마크 수치는 이 문서의 관심사가 아니고, 컴파일 여부만 확인했다.
  • messaging-policy/messaging-transport-spi 의존이 언제 왜 추가되었는지 — git 이력 입도 부족.
  • Rabbit 강등이 언제 일어났는지 — 마찬가지.

17. 손볼 것

P2 — FaultController 의 5개 중 2개가 구현만 3벌 있고 호출부가 0건이다

rejectPublish()reset() 은 인터페이스에 선언되어 세 하니스가 전부 구현하지만, 계약 스위트를 포함해 어디에서도 호출되지 않는다(EVD-299). rejectPublish 는 심지어 세 하니스의 publish() 경로에 완전히 배선되어 있다(KafkaContractHarness:119, RabbitContractHarness:85, InMemoryMessagingHarness:66) — 켜는 스위치만 아무도 누르지 않는다.

이것이 단순한 미사용 코드가 아닌 이유: 미사용 경로가 틀린 값을 인코딩하고 있다. InMemoryMessagingHarness 에서 rejectPublishrejected("BROKER_REJECTED", …) 를 돌려주고, 그 헬퍼는 PublishEvidence.notTransmitted() 를 쓴다(:184-193). TransmissionEvidence.NOT_TRANSMITTED 의 javadoc 은 "Nothing was written to the broker connection." 이다. 그런데 FaultController.rejectPublish 의 javadoc 은 "refused outright by the broker" 다 — 브로커가 거절하려면 바이트가 나갔어야 하므로 TRANSMITTED 여야 한다. 이 플랫폼은 전송 증거를 세 값으로 구분하는 것을 핵심 가치로 삼는데, 유일하게 실행되지 않는 경로에 그 구분의 오류가 들어 있다.

connection-refused 시나리오(유일하게 증거가 없는 시나리오, Expectation.REJECTED)와 이 미사용 결함이 같은 빈칸을 가리킨다. 둘 중 하나를 택해야 한다: 계약에 rejectsWhenBrokerRefusesBeforeTransmission 를 추가하고 전송 증거를 바로잡거나, rejectPublish 를 인터페이스에서 제거해 세 하니스의 구현 부담을 없애거나.

reset() 은 별개다. 세 구현 모두 결함 플래그를 one-shot 으로 소비하므로(consumeXxx 가 읽고 즉시 false) 리셋이 필요 없는 구조다. 계약이 테스트마다 새 하니스를 만드는 것도 같은 이유다. 제거 후보다.

P2 — 클래스 javadoc 이 강제되지 않는 규칙을 강제된다고 말한다

BrokerFailureMatrix.java:18-20 이 "A Stable adapter must cover every scenario. That rule is enforced by a test rather than documented" 라고 쓰고 있으나, isComplete 를 Stable 어댑터에 거는 테스트는 없다(EVD-300). 유일한 호출부는 Experimental 어댑터가 불완전함을 단언한다. 실제 Stable 인 messaging-kafkaconnection-refused gap 을 가진 채 통과하며, 그 gap 은 같은 모듈이 명시적으로 단언한다.

코드 쪽 결정("gap 을 열거하되 비어 있음을 단언하지 않는다")은 옳고, 그 이유도 CrossBrokerContractSuite.java:45-47 에 적혀 있다. 문제는 javadoc 이 갱신되지 않은 것이다. 이 리프의 다른 javadoc 여섯 곳이 자기 이력을 정확히 남긴 것과 대비되어 더 눈에 띈다.

같은 이유로 테스트 메서드 이름 everyStableAdapterCoversEveryFaultScenario 도 본문과 맞지 않는다. everyStableAdaptersGapsAreExactlyWhatTheEvidenceShows 같은 이름이 본문을 정확히 기술한다.

수정 방향: javadoc 을 현재 규칙("Stable 은 live-broker 증거를 하나 이상 요구한다. 전 시나리오 커버리지는 목표이지 게이트가 아니며, gap 은 knownGaps 로 명명된다")으로 바꾸고, 테스트 이름을 본문에 맞춘다.

P3 — Faults 내부클래스 57줄이 3개 모듈에 바이트 단위로 복제되어 있다

sha256 이 세 곳 모두 3028b459… 로 동일하다(EVD-299). 총 171줄. messaging-testkit/src/mainDefaultFaultController (또는 RecordingFaultController) 하나를 두고 세 하니스가 그것을 필드로 갖게 하면 된다. MessagingAdapterHarness.faults() 의 반환 타입은 FaultController 그대로이므로 외부 API 변경이 없다.

이 복제가 위험한 이유는 P2 와 겹친다: rejectPublish 의 전송 증거를 고치려면 지금은 세 파일을 고쳐야 하고, 세 파일이 어긋나면 어댑터마다 다른 결함 의미를 갖게 된다 — 이 리프가 존재하는 이유 자체를 무너뜨린다.

P3 — 1 MiB 한도가 PayloadPolicy 를 두고 리터럴로 재선언된다

PayloadPolicy.DEFAULT_MAX_BYTES = 1_048_576 이 정본인데 같은 값이 최소 8곳에 다시 있고(§12.3(b)), 그중 둘이 이 리프다(InMemoryMessagingHarness:31, ContractMessage:50). messaging-testkitapi project(':messaging:messaging-policy') 를 이미 선언하고 있으므로 import 한 줄이면 된다. 지금은 messaging-policy 의존이 import 0건이라 선언만 남아 있는데(§12.4(b)), 이 자리가 그 의존이 실제로 쓰여야 할 곳이다.

ContractMessage.oversized()1_048_577PayloadPolicy.DEFAULT_MAX_BYTES + 1 로 쓰면 "한도 바로 위 한 바이트" 라는 의도가 코드에 드러난다.

P3 — messaging-transport-spi 의존이 import 0건이다

policy 와 달리 transport-spi 는 쓸 자리가 보이지 않는다. 제거 후보.

P3 — BrokerFailureMatrix.adapters() 는 호출부가 0건이다

public 메서드이나 아무도 쓰지 않는다. 이 리프의 다른 public 표면은 전부 소비자가 있다. 제거하거나, 진단용이라면 그렇게 적는다.

P3 — 항등식을 단언하는 테스트가 하나 있다

CompatibilityMatrixTest.aCertificationClaimCannotBeMadeWithoutEvidence(:48-60)의 좌변과 우변이 같은 식이다(§12.3(c)). 이름이 약속하는 것을 검사하지 않는다. 실질 검사는 같은 파일의 다른 두 테스트가 하고 있으므로 커버리지 손실은 없다. 이 테스트를 지우거나, "증거를 비우면 Stable 주장이 무너진다" 를 실제로 검사하도록 바꾼다 — 후자가 이름에 맞는다.

P3 — gitCommit 은 기록되지만 읽혀 판정되지 않는다

BrokerCertificationEvidence javadoc 이 "without them the evidence cannot be checked against anything later" 라고 쓰지만, 실제로 gitCommit 을 읽어 무언가를 결정하는 코드는 없고 게이트는 오히려 그 필드를 비교에서 제외한다(§12.4(c)). 현재 매니페스트의 커밋은 HEAD 가 아니다(e98b56eb vs 21234e38). "증거가 얼마나 오래된 트리에서 나왔는가" 를 보고하는 것은 유용한 진단이 될 수 있다 — 게이트로 만들 필요는 없고, knownGaps 처럼 사실로 노출하면 이 리프의 나머지 설계와 결이 맞는다.

확인된 설계(문제 아님)
  • 매니페스트를 커밋하고, 게이트가 손편집을 막는 구조. 재현 가능성과 깨끗한 체크아웃 두 요구를 동시에 만족한다.
  • requireOutcomeMatchesExpectation. "틀린 증거는 무증거보다 나쁘다" 는 판단과 그 근거가 코드·주석·테스트 셋 다에 있다.
  • 부재는 NOT_COVERED, 읽기 실패는 예외. 조용한 강등을 막는 구분.
  • 파싱이 누락 필드와 미지 필드를 둘 다 거절한다. 조용한 절단 대신 실패.
  • 인증 레인에만 Docker 가드를 두지 않는 결정. skip 이 성공으로 보고되는 것을 막으면서 노트북 빌드를 지킨다.
  • jmh 를 소스셋으로 두어 매 빌드 컴파일 / 온디맨드 실행. EVD-298 로 그래프에서 실측 확인했다. 꺼진 spotbugsJmh/checkstyleJmh 가 여전히 compileJmhJava 를 끌고 온다.
  • 계약 크기를 리플렉션으로 잠근 것. 테스트를 조용히 지울 수 없다.
  • 문서 검사를 좁게 유지한 것. 삭제당하지 않을 만큼만 검사한다.
  • noEnumConstantTheDocsDenyActuallyExists. 문서의 부정 주장을 코드로 검증하는 드문 방향.
  • InMemoryMessagingHarnesssrc/test 에 두고 package-private + final 로 막은 것. 물리적으로 리프 밖으로 못 나간다.

Source anchors

src/messaging/messaging-testkit/build.gradle:1-11
src/build.gradle:500-542                        (jmh 소스셋 3리프 한정 + 게이트 제외 사유)
src/config/architecture/modules.json            (messaging-testkit 항목)

main/…/MessagingAdapterContract.java:10-19,26,28-134
main/…/MessagingAdapterHarness.java:8-15,61-72
main/…/FaultController.java:3-25
main/…/ContractMessage.java:17-26,29,40-51
main/…/ContractAssertions.java:20-50
main/…/ObservedDelivery.java:14-22
main/…/HandleOutcome.java:4-13
main/…/NetworkFaultScenario.java:7-17,52-63,65-112,114-136,143-150
main/…/BrokerCertificationEvidence.java:8-25,35-56,58-114,116-145
main/…/CertifiedEvidence.java:9-27,31-34,43-64,66-83
main/…/BrokerFailureMatrix.java:9-20,21-33,43-51,60-64,72-76,84-87,95-97,104-106,108-127,129-146
main/…/CompatibilityMatrix.java:7-17,22-32,34-75,50-62,77-109,116-132
main/…/DockerAvailability.java:3-34
main/resources/messaging/broker-certification-evidence.jsonl  (4줄)

test/…/InMemoryMessagingHarness.java:22-29,31,57-96,98-120,122-153,184-197,199-259
test/…/InMemoryHarnessContractTest.java:5-21
test/…/CompatibilityMatrixTest.java:13-21,23-46,48-60,62-69,71-80,82-95,97-127
test/…/CertifiedEvidenceTest.java:12-18,21-32,34-49,51-59,61-74,76-96,98-112,114-126
test/…/CrossBrokerContractSuite.java:9-19,22-34,36-41,43-54,56-73,75-88,90-106,108-114,116-120,122-145
test/…/MessagingDocumentationContractTest.java:11-20,23-40,42-58,60-89,91-99,101-112,114-133,135-144
jmh/…/EnvelopeCodecBenchmark.java:31-41,43-49,55-69,71-125

src/messaging/messaging-kafka/build.gradle:34-120   (strictTestLanes + verifyMessagingCertificationEvidence)
src/messaging/messaging-kafka/src/test/java/.../KafkaContractHarness.java:69,102,119,312-368
src/messaging/messaging-rabbit/src/test/java/.../RabbitContractHarness.java:38,40,85,246-300
src/messaging/messaging-policy/src/main/java/.../PayloadPolicy.java:17
src/messaging/messaging-core-api/src/main/java/.../publish/TransmissionEvidence.java:4-13
src/messaging/messaging-core-api/src/main/java/.../publish/PublishEvidence.java:29,39-64

A19-MESSAGING-TRANSPORT-SPI. messaging-transport-spi

분석 중에는 messaging/MESSAGING-TRANSPORT-SPI.md 파일이었다. 716줄.

messaging-transport-spi 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-transport-spi SSOT owner: messaging-transport-spi integration/family document: §A19 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-transport-spi
  • canonical state analysisFile: §A19-MESSAGING-TRANSPORT-SPI
  • source path: src/messaging/messaging-transport-spi
  • registry allowed_dependencies: ["messaging-core-api", "messaging-schema-api", "messaging-policy"]
  • registry runtime_memberships: ["app-bootstrap"]
숫자
항목
production Java 파일 13
production LOC 776
패키지 1 (dev.caskeleton.messaging.transport)
test 파일 4
test 메서드(실행 확인) 24
외부(비프로젝트) 의존성 0

13개 타입:

타입 종류 역할
MessagingTransport interface 브로커 어댑터가 구현하는 SPI
TransportPublishRequest record 이미 인코딩된 발행 요청
TransportPublishResult record PublishResult 래퍼
TransportConsumerSpec record 프로파일 + 콜백
TransportConsumerRegistration interface 살아 있는 구독
TransportDelivery record 아직 인코딩된 수신
TransportSettlement interface 어댑터 측 정산 핸들
MessagingRuntime interface 한 세대의 연결·자격증명·토폴로지
MessagingRuntimeLease interface 세대 참조 대여
MessagingRuntimeRegistry interface 브로커별 현재 세대
DefaultMessagingRuntimeRegistry class 참조 계수 + 원자 교체 구현
GracefulShutdownCoordinator class 드레인 조정자
MessagingLifecycle interface 8단계 종료 순서 계약 — 구현체 없음(§12.1)
Coverage ledger
scope/file group count disposition reason
src/main/java/** (13) 13 FULL_READ 전 파일 본문 확인
src/test/java/** (4) 4 FULL_READ 전 파일 본문 확인
build.gradle 1 FULL_READ 7줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

브로커 어댑터가 구현할 SPI와, 그 어댑터들의 수명주기·세대 관리를 소유한다. 벤더 의존성이 0이다.

가장 중요한 경계 규칙이 MessagingTransport의 javadoc에 있다.

// MessagingTransport.java:10-12
 * <p>No method returns a native client object. Handing back a raw producer or channel would let an
 * application bypass destination policy, payload limits, and the settlement ordering in one call,
 * and the resulting code would silently stop working the moment the broker changed.

13개 타입 중 어느 것도 브로커 네이티브 타입을 시그니처에 노출하지 않는다. BrokerPosition(core-api)이 Map<String,String> diagnosticAttributes()로 좌표를 문자열로만 내보내는 것과 같은 규율이다.

두 번째 경계는 인코딩 위치다.

// TransportDelivery.java:11-13
 * <p>Decoding happens above the transport so that a payload the consumer cannot parse is classified
 * as a schema failure by the platform, and parked, rather than being turned into an
 * adapter-specific exception each broker reports differently.

TransportPublishRequest도 대칭이다 — "The payload arrives already encoded and the profile arrives already validated, so an adapter never chooses a codec or a limit for itself. That is what keeps two adapters from disagreeing about what 'the same message' means."


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), messaging-schema-api(api), messaging-policy(api). 셋 다 api인 이유는 세 leaf의 타입이 이 leaf의 public 시그니처에 직접 등장하기 때문이다 — TransportPublishRequestDestinationProfile(policy)·MessageEnvelope(core-api)·EncodedMessage(schema-api)를 필드로 갖는다.

나가는 것: messaging-runtime-core, messaging-kafka, messaging-kafka-share-experimental, messaging-rabbit, messaging-pulsar-experimental, messaging-nats-experimental, messaging-admin-runtime, messaging-spring-cloud-stream-bridge, messaging-spring-boot-starter, messaging-testkit.

런타임 편입은 starter closure를 통해서다. 이 leaf 자체는 bean을 만들지 않는다.


3. 패키지/컴포넌트 지도

세 축이 한 패키지에 있다.

[SPI]  MessagingTransport
       ├── publish(TransportPublishRequest) → TransportPublishResult
       ├── register(TransportConsumerSpec)  → TransportConsumerRegistration
       ├── capabilities(DestinationName)    → DestinationCapabilities
       └── brokerName / generation / close
                    ↑ 구현: Kafka · Rabbit · Pulsar · NATS (4)

[세대] MessagingRuntime ── MessagingRuntimeLease ── MessagingRuntimeRegistry
                                                          ↑
                                        DefaultMessagingRuntimeRegistry (구현)

[종료] GracefulShutdownCoordinator          (사용됨: 11개 파일)
       MessagingLifecycle.ShutdownPhase(8)  (구현 없음, 소비자 0)

세 축이 다른 정도로 살아 있다. SPI는 4개 어댑터가 구현하고, 세대 관리는 구현이 하나 있고, 종료 계약은 절반만 실현됐다(§12.1).


4. 계약·불변식·상태 모델

4.1 세대 모델: 회전은 변경이 아니라 교체다
// MessagingRuntime.java:5-8
 * <p>Credential rotation and topology reload replace a whole generation rather than mutating a live
 * one. In-flight publishes keep the generation they started on, which is what makes a rotation
 * invisible to callers instead of a burst of authentication failures.

세 타입이 그 모델을 이룬다.

타입 불변식
MessagingRuntime 불변. close()멱등이어야 한다(javadoc이 명시)
MessagingRuntimeLease 참조를 pin. close()멱등이어야 한다
MessagingRuntimeRegistry 브로커당 현재 세대 하나
4.2 DefaultMessagingRuntimeRegistry: 참조 계수와 원자 교체

이 leaf의 유일한 실질 구현이고 동시성 설계가 조밀하다.

설치(교체)

Generation retired = current.put(runtime.brokerName(), new Generation(runtime));
if (retired == null) return;
retired.retire(now);
if (!retired.closeIfIdle()) {
  synchronized (draining) { draining.add(retired); }
}

ConcurrentHashMap.put이 원자적이므로 호출자는 옛 세대 또는 새 세대만 본다 — javadoc: "never a half-rebuilt connection pool".

대여

Generation generation = current.computeIfPresent(brokerName, (key, value) -> {
  value.leases.incrementAndGet();
  return value;
});

computeIfPresent의 리맵 함수가 버킷 잠금 안에서 실행되므로, 조회와 증가가 원자적이다. get 후 증가였다면 그 사이에 install이 세대를 교체해 이미 은퇴한 세대의 계수를 올릴 수 있다.

해제

void release() {
  if (leases.decrementAndGet() == 0) { closeIfIdle(); }
}
boolean closeIfIdle() {
  if (retired.get() && leases.get() == 0) { return forceClose(); }
  return false;
}
boolean forceClose() {
  if (closed.compareAndSet(false, true)) { runtime.close(); return true; }
  return false;
}

closed가 CAS로 보호되므로 정확히 한 번만 runtime.close()가 불린다. 테스트가 그것을 직접 단언한다(aRetiredGenerationIsClosedExactlyOnce, as("a second close on a real connection pool throws from a shutdown hook")).

Lease.close()도 자체 AtomicBoolean released로 멱등이다 — 두 층의 멱등성이다.

세대별 은퇴 시각

// Generation.retiredAt javadoc:180-183
 * <p>Each generation carries its own. The deadline check took one {@code retiredAt} from the
 * caller and applied it to every draining generation, so a rotation during a drain either
 * force-closed a generation that had just retired or gave an old one a fresh deadline 
 * depending on which timestamp the caller happened to pass.

이전 결함의 기록이다. 하나의 타임스탬프를 전체 목록에 적용하면 회전이 겹칠 때 판정이 호출자가 우연히 넘긴 값에 좌우된다.

닫힌 세대의 목록 제거

// closeExpiredDraining:112-113
// Anything already closed leaves the list too: it is not draining, and leaving it there is
// what made drainingCount report work that had finished.
draining.removeIf(Generation::isClosed);

drainingCount()가 관측 지표이므로, 이미 닫힌 세대가 목록에 남으면 지표가 영원히 0으로 안 떨어진다.

close()가 현재 세대까지 닫는다

// close() javadoc:131-134
 * <p>Nothing closed the current generation. The registry only ever closed what a rotation had
 * retired, so a process that shut down without rotating left its broker connections to the JVM's
 * exit  which drops unflushed producer batches and leaves consumer sessions to time out on the
 * broker instead of leaving the group.

이것도 이전 결함이다. 회전 없이 종료하는 프로세스(=대부분의 프로세스)가 연결을 정리하지 않았다.

동시성 미세 결함 하나. close()drainingsynchronized로 비우지만 currentList.copyOf(current.keySet()) 후 하나씩 remove한다. 그 사이에 install이 새 세대를 넣으면 그 세대는 닫히지 않는다. 종료 중 설치는 정상 시나리오가 아니므로 실질 위험은 낮다 — §17의 P3.

4.3 GracefulShutdownCoordinator: 세 단계와 그 이유
// GracefulShutdownCoordinator.java:12-22
 * <p>Shutdown has three phases, in order: stop accepting new work, let what is running finish, then
 * close. Skipping the middle phase is what produces the classic shutdown bug  a handler is
 * interrupted between its side effect and its settlement, so the message is redelivered and the
 * effect happens twice.
 *
 * <p>The deadline exists because draining cannot be unbounded: a stuck handler would otherwise hold
 * the process open forever. Work still running at the deadline is abandoned <em>unsettled</em>, so
 * the broker redelivers it rather than the platform pretending it completed.
 *
 * <p>No retry attempt is created once draining begins. Starting a fresh attempt during shutdown
 * guarantees it will be abandoned at the deadline.

tryBeginWork이중 검사다.

public boolean tryBeginWork() {
  if (draining.get()) return false;
  inFlight.incrementAndGet();
  if (draining.get()) { inFlight.decrementAndGet(); return false; }
  return true;
}

증가 후 다시 확인해서, 증가와 beginDrain 사이의 경합에서 계수를 되돌린다. 이 패턴이 없으면 드레인 시작 직후 시작된 작업이 계수에 남아 isDrained가 영원히 false가 된다.

endWork가 0에서 clamp한다.

// :65-67
 * <p>Clamped at zero. A double release used to drive the count negative, and a negative in-flight
 * count reports the drain as complete while work is still running  which is exactly when the
 * process shuts down underneath it.
public void endWork() {
  inFlight.updateAndGet(current -> current > 0 ? current - 1 : current);
}

isDrained(now)가 세 갈래다 — 드레인 전이면 false, 계수 0이면 true, 아니면 마감 경과 여부. abandonedWorkAtDeadline이 "마감으로 끝났는가"를 별도로 답해서, 완주한 드레인과 포기한 드레인을 구분할 수 있다.

4.4 MessagingLifecycle: 8단계 순서 계약
// MessagingLifecycle.java:8-15
 * <p>The order in {@link ShutdownPhase} is the contract, not an implementation detail. Closing
 * connections before settlements have been transmitted loses the settlements, and pausing consumers
 * after draining lets fresh deliveries arrive into a runtime that is already shutting down. Each
 * adapter implements the phases; none of them chooses the order.
 *
 * <p>Implementations are driven by the Spring lifecycle rather than a JVM shutdown hook alone. A
 * shutdown hook runs after the context has already begun disposing beans, so a handler mid-drain
 * can find its datasource closed underneath it.

여덟 단계:

# 단계
1 STOP_PUBLISH_ADMISSION 새 발행 거부
2 STOP_NEW_HANDLERS 새 핸들러 시작 거부
3 PAUSE_CONSUMERS 브로커에 전달 중단 요청
4 DRAIN_HANDLERS 실행 중 핸들러 완료 대기
5 FLUSH_SETTLEMENTS 그 핸들러들이 만든 정산 전송
6 AWAIT_PRODUCER_CONFIRMS 미확인 발행이 모호로 남지 않게
7 RELEASE_OUTBOX_LEASES 다른 relay가 즉시 claim 가능하게
8 CLOSE_CONNECTIONS 연결·채널 종료

shutdown(Duration)이 마감 시점에 실행 중이던 단계를 반환한다 — 완주하면 CLOSE_CONNECTIONS.

이 인터페이스를 구현하는 것이 저장소에 없다. §12.1.

4.5 TransportConsumerRegistration: 순서 단위별 pause
// :8-10
 * <p>Pause and resume operate on an ordering unit rather than the whole consumer, because that is
 * what makes {@code PAUSE_PARTITION} retry possible: one stuck key must not stall every other
 * partition on the same connection.

scope가 빈 문자열이면 전체다. core-apiPauseResumeController"*"를 전체로 쓴다 — 두 인터페이스가 같은 개념에 다른 sentinel을 쓴다. PauseResumeController는 소비자가 0이므로(§A19-MESSAGING-CORE-API §12.1) 오늘 충돌하지 않지만, 그것을 배선하려는 사람이 두 규약을 이어야 한다.

4.6 TransportSettlement: 애플리케이션에 노출되지 않는다
// :10-11
 * <p>Deliberately not exposed to application code. Handlers state an intent; the platform decides
 * when and in what order the settlement happens, and this is the seam it uses to do that.

acknowledge / requeue(delay) / discard 셋이고, core-apiSettlementController(ack/retry/deadLetter/reject)와 이름도 개수도 다르다. 전자는 어댑터 측 원시 연산, 후자는 M2 수동 정산 API다. deadLetter가 전자에 없는 것이 핵심이다 — DLQ 발행은 플랫폼(DefaultDeliveryProcessor)이 하고 어댑터는 acknowledge만 받는다.


5. 주요 실행 경로

발행: 상위(DefaultMessagePublisher)가 TransportPublishRequest를 만들어 MessagingTransport.publish → 어댑터가 TransportPublishResult(PublishResult) 반환

수신: 상위가 TransportConsumerSpec(profile, sink)register → 어댑터가 메시지마다 sink.apply(TransportDelivery) → 상위가 TransportSettlement으로 정산

회전:MessagingRuntime 생성 → registry.install(runtime, now) → 옛 세대 retire → lease가 0이면 즉시 close, 아니면 draining에 적재 → 스케줄러가 closeExpiredDraining(now) 호출

종료: (실제 경로) MessagingShutdownLifecycle.stop()admission.stopAcceptingNewWork()drain.beginDrain(now) → 50 ms 폴링으로 isDrained 대기 → 마감 도달 시 중단


6. 실패 경로와 복구/번역

이 leaf가 직접 던지는 예외는 하나다.

코드 예외 조건
RUNTIME_NOT_INSTALLED MessagingConfigurationException acquire(brokerName)인데 그 브로커의 세대가 없음

나머지는 IllegalArgumentException(생성자 인자 검증)과 NullPointerException(Objects.requireNonNull)이다. 이 leaf가 다루는 실패의 대부분은 예외가 아니라 상태다 — 드레인 마감 초과는 abandonedWorkAtDeadline(now)가 true를 반환하는 것이고, 세대 강제 종료는 closeExpiredDraining의 반환 계수다.

포기가 조용하지 않다는 것이 설계다. 마감에 도달한 작업은 정산되지 않은 채 버려지고, 브로커가 재전달한다. GracefulShutdownCoordinator javadoc: "rather than the platform pretending it completed."


7. 트랜잭션·동시성·수명주기

이 leaf는 messaging family에서 동시성 밀도가 가장 높다.

지점 도구 보호하는 것
current ConcurrentHashMap 세대 교체의 원자성
lease 증가 computeIfPresent 리맵 조회-증가 사이의 교체
leases AtomicInteger 참조 계수
retired, closed AtomicBoolean + CAS 정확히 한 번 close
Lease.released AtomicBoolean + CAS 이중 close 방지
retiredAt volatile Instant 세대별 마감 가시성
draining 리스트 synchronized 블록 ArrayList 보호
inFlight AtomicInteger + 이중 검사 + clamp 드레인 계수
draining(coordinator) AtomicBoolean CAS 드레인 시작 한 번
drainStartedAt volatile Instant 마감 가시성

주목할 비대칭: DefaultMessagingRuntimeRegistrycurrent는 lock-free(ConcurrentHashMap)로, drainingsynchronized ArrayList로 다룬다. draining은 회전 때만 접근하므로 경합이 없다 — 합리적 선택이지만 주석이 없다.

수명주기는 §4.4의 8단계가 선언이고 §12.1이 실현 상태를 다룬다.


8. 설정·기능 플래그·환경 차이

설정 없음.

상수 위치
DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE 30초 :28 (private)
MessagingLifecycle.DEFAULT_DRAIN_DEADLINE 30초 :40 (public, 인터페이스 상수)

같은 값이 두 곳에 있다. 그리고 MessagingShutdownLifecycle(starter)은 셋 중 어느 것도 참조하지 않고 생성자 인자로 받는다. 세 번째 값이 프로퍼티에서 올 수 있다는 뜻이다 — 그 배선은 starter leaf가 소유한다.


9. 퍼시스턴스/외부 시스템 세부

없다. 이 leaf는 브로커를 만지지 않는다 — 만지는 방법의 모양만 정의한다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-transport-spi:test. BUILD SUCCESSFUL, 24 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
MessagingRuntimeRegistryTest 9 세대 설치·대여·은퇴·드레인 계수 실제 브로커 연결
ResourceLeakGateTest 4 20세대 연속 회전 후 현재 세대만 열림, 누수 lease가 마감에 강제 종료, 막힌 작업도 드레인 종료, 은퇴 세대가 정확히 한 번 close 며칠 단위 실행
GracefulShutdownTest 5 드레인이 새 작업만 막고 실행 중은 완료, 재시도 금지, 마감 경계(29초 false / 30초 true), 유휴 코디네이터, 이중 endWork clamp
MessagingLifecycleTest 6 enum 선언 순서와 상수 값 아무 종료 동작도 증명하지 않는다
10.1 ResourceLeakGateTest의 자기 규정
// :12-18
 * <p>Every resource the platform holds is bounded by something that must eventually release it: a
 * runtime generation by its last lease, an in-flight slot by its handler finishing, a drain by its
 * deadline. Each of those has a failure mode that is invisible in a short test and fatal over days
 *  a retired generation whose credential never gets revoked, a partition that never accepts work
 * again, a shutdown that never completes.

세 자원과 각각의 해제 조건을 명시하고, "짧은 테스트에서 안 보이고 며칠이면 치명적"이라는 실패 성격까지 적는다. 20세대 회전 루프가 그 형태를 압축한 것이다.

10.2 MessagingLifecycleTest가 실제로 단언하는 것

여섯 테스트 중 다섯이 이 형태다.

List<ShutdownPhase> order = List.of(ShutdownPhase.values());
assertThat(order.indexOf(ShutdownPhase.DRAIN_HANDLERS))
    .as("flushing before the handlers finish would lose the settlements they produce")
    .isLessThan(order.indexOf(ShutdownPhase.FLUSH_SETTLEMENTS));

ShutdownPhase.values()소스에 상수가 적힌 순서를 반환한다. 이 단언이 검증하는 것은 "누군가 enum 상수를 이 순서로 타이핑했다"이다. 여섯 번째는 상수 값 비교(DEFAULT_DRAIN_DEADLINE == 30초)다.

as(...) 문구들은 실제 시스템 동작을 서술한다 — "flushing before the handlers finish would lose the settlements", "a confirm that arrives after close cannot be observed". 그러나 그 동작을 수행하는 코드가 없다(§12.1). 테스트 이름(handlersDrainBeforeTheirSettlementsAreFlushed)과 실제 단언(enum 인덱스 비교) 사이의 거리가 이 레인에서 가장 큰 항목이다.

이 여섯 테스트는 enum 상수 순서를 바꾸면 실패한다. 그리고 순서를 바꿔도 시스템 동작은 바뀌지 않는다 — 아무도 그 순서를 읽지 않기 때문이다. 게이트가 지키는 것과 게이트가 지킨다고 이름 붙인 것이 다르다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies 세 project 의존
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 벤더 의존성 0이므로 대상 없음. 세 project 의존은 전부 api이고 시그니처에 실제로 등장
ArchUnit 전용 규칙 없음
MessagingLifecycle 구현 강제 없음 — 인터페이스는 컴파일 타임 강제를 만들지 않는다

마지막 행이 §12.1의 구조적 이유다. MessagingTransport는 어댑터가 구현하지 않으면 TransportMessagingRuntime이 컴파일되지 않는다. MessagingLifecycle은 아무도 받지 않으므로 구현하지 않아도 아무것도 깨지지 않는다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/280-transport-spi-lifecycle-unimplemented.txt.

12.1 Public surface reachability
타입 leaf 밖 파일 수 판정
TransportPublishRequest 18 활발
TransportConsumerSpec 14 활발
MessagingTransport 12 4개 어댑터가 구현
GracefulShutdownCoordinator 11 활발
TransportPublishResult 10 활발
TransportDelivery 9 활발
TransportConsumerRegistration 8 활발
TransportSettlement 4 활발
MessagingRuntime 3 TransportMessagingRuntime이 구현
MessagingRuntimeRegistry 3
MessagingRuntimeLease 2
DefaultMessagingRuntimeRegistry 2
MessagingLifecycle 0 구현 없음, 소비자 없음

이 leaf는 messaging family에서 가장 잘 쓰이는 leaf 중 하나다. 13개 중 12개가 실제 소비자를 갖는다. 그래서 나머지 하나가 두드러진다.

MessagingLifecycle의 세 겹 부재

  1. git grep -E 'implements .*MessagingLifecycle' → exit 1. 구현체 없음.
  2. git grep -w ShutdownPhase -- src ':!src/messaging/messaging-transport-spi' → exit 1. 8단계 enum의 외부 소비자 없음.
  3. MessagingLifecycle의 저장소 전체 언급이 자기 선언과 자기 테스트 두 줄뿐.

한편 MessagingTransport는 넷이 구현한다 — KafkaMessagingTransport, RabbitMessagingTransport, PulsarMessagingTransport, NatsJetStreamTransport. 네 어댑터 중 어느 것도 MessagingLifecycle을 구현하지 않는다. javadoc이 "Each adapter implements the phases"라고 적은 그 어댑터들이다.

실제 종료 경로는 존재하고 다른 타입으로 되어 있다.

messaging-spring-boot-starterMessagingShutdownLifecycle implements SmartLifecycle이 종료를 수행한다.

public void stop() {
  if (!running.compareAndSet(true, false)) return;
  admission.stopAcceptingNewWork();          // ≈ phase 1
  Instant startedAt = clock.get();
  drain.beginDrain(startedAt);               // ≈ phase 2
  Instant deadline = startedAt.plus(drainDeadline);
  while (!drain.isDrained(clock.get()) && clock.get().isBefore(deadline)) { ... }  // ≈ phase 4
}

선언된 8단계와 대조:

# 선언 단계 실제 수행
1 STOP_PUBLISH_ADMISSION 수행admission.stopAcceptingNewWork()
2 STOP_NEW_HANDLERS 수행beginDrain 이후 tryBeginWork()가 false
3 PAUSE_CONSUMERS 명시적 호출 없음. 어댑터의 registrar가 자체 처리
4 DRAIN_HANDLERS 수행 — 폴링 루프
5 FLUSH_SETTLEMENTS 명시적 단계 없음
6 AWAIT_PRODUCER_CONFIRMS 명시적 단계 없음
7 RELEASE_OUTBOX_LEASES 명시적 단계 없음 — getPhase() javadoc이 outbox relay와의 상대 순서만 언급
8 CLOSE_CONNECTIONS Spring bean 소멸에 위임 — getPhase()Integer.MAX_VALUE - 1024로 transport보다 먼저 멈춤

8단계 중 셋이 명시적으로 수행되고, 하나는 Spring 단계 순서에 위임되며, 넷은 명시적 단계가 없다. 그리고 순서를 결정하는 것은 ShutdownPhase enum이 아니라 Spring의 getPhase() 정수다.

MessagingShutdownLifecycle의 javadoc이 자기 순서를 스스로 설명한다 — "The order is admission first, drain second. Reversed, the drain waits for a count that new work keeps topping up." 두 단계에 대해서만 순서를 논한다.

한계. PAUSE_CONSUMERS·FLUSH_SETTLEMENTS·AWAIT_PRODUCER_CONFIRMS가 어댑터 내부에서 다른 이름으로 수행될 수 있다. KafkaConsumerRegistrarRabbitConsumerRegistrarGracefulShutdownCoordinator를 쓰므로 그 leaf들이 답을 갖는다. 이 문서는 ShutdownPhase가 그 순서를 결정하지 않는다만 주장한다.

12.2 Conditional sibling comparison

Spring 주석 0개, bean 없음.

MessagingTransport 구현 sibling 넷의 비대칭이 관측된다.

어댑터 MessagingTransport registry membership
KafkaMessagingTransport o ["app-bootstrap"]
RabbitMessagingTransport o ["app-bootstrap"]
PulsarMessagingTransport o []
NatsJetStreamTransport o []

넷 다 같은 SPI를 구현하고 둘만 편입된다 — docs/messaging/support-matrix.md의 experimental 구분과 정합한다. 각 어댑터의 조건부 활성화는 해당 leaf SSOT가 소유한다.

12.3 Duplicate mechanism sweep

(a) 드레인 마감 30초가 세 곳에 있다

위치 가시성
MessagingLifecycle.DEFAULT_DRAIN_DEADLINE public 인터페이스 상수
DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE private
MessagingShutdownLifecycle(starter) 생성자 인자

public 상수가 있는데 같은 leaf의 다른 클래스가 자기 private 복사본을 쓴다. MessagingLifecycleTest의 여섯 번째 테스트가 public 쪽만 고정한다 — private 쪽이 바뀌어도 통과한다.

(b) 정산 인터페이스가 둘

인터페이스 leaf 연산
TransportSettlement 이 leaf acknowledge / requeue(delay) / discard
SettlementController messaging-core-api ack / retry(delay) / deadLetter(failure) / reject(failure)

책임이 다르다 — 전자는 어댑터 원시 연산, 후자는 M2 수동 정산 API이고 deadLetter가 추가돼 있다. 중복이 아니라 계층이다. 다만 SettlementController는 소비자가 0이므로(messaging-core-api §12.1) 오늘 계층의 위쪽이 비어 있다.

(c) pause scope sentinel이 둘

인터페이스 전체를 뜻하는 값
TransportConsumerRegistration.pause(String scope) 빈 문자열
PauseResumeController.pause(dest, String scope) (core-api) "*"

두 javadoc이 각각 명시한다. 이으려면 변환이 필요하고, 그 변환 코드는 없다(PauseResumeController 소비자 0).

(d) 드레인 조정 로직

GracefulShutdownCoordinator가 유일하다. 저장소의 다른 곳에서 in-flight 계수 + 마감 패턴을 다시 만든 곳은 messaging family 안에 없다. 다른 family(grpc의 admission controller 등)와의 비교는 cross-scope가 소유한다.

12.4 Documentation / measured-count drift
문서 주장 재측정 결과
MessagingLifecycle javadoc: "Each adapter implements the phases" 4개 어댑터 중 0개 구현 불일치
MessagingLifecycle javadoc: "The order in ShutdownPhase is the contract" 그 순서를 읽는 코드 0 불일치
MessagingTransport javadoc: 네이티브 클라이언트 미반환 13개 타입 시그니처 전수 확인 일치
TransportDelivery javadoc: 디코딩이 transport 위에서 TransportDelivery.envelopeMessageEnvelope<EncodedMessage> 일치
TransportSettlement javadoc: 애플리케이션에 미노출 이 leaf가 ..application..에서 참조 0(ArchUnit이 금지) 일치
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift, messaging-core-api §12.4가 소유)

13. Git/설계 문서에서 확인한 변화와 실패 기록

코드 주석이 네 결함을 보존한다. 전부 장기 실행에서만 드러나는 종류다.

위치 이전 상태 그것이 만든 실패
Generation.retiredAt javadoc 호출자가 넘긴 하나의 retiredAt을 전체 draining 목록에 적용 드레인 중 회전이 겹치면, 방금 은퇴한 세대를 강제 종료하거나 오래된 세대에 새 마감을 주거나 — 호출자가 우연히 넘긴 타임스탬프에 좌우
closeExpiredDraining 주석 이미 닫힌 세대가 목록에 잔류 drainingCount()가 끝난 작업을 영원히 보고
close() javadoc 회전이 은퇴시킨 것만 닫음 회전 없이 종료한 프로세스가 브로커 연결을 JVM 종료에 맡김 → 미전송 producer 배치 소실, consumer 세션이 그룹을 떠나지 않고 브로커에서 타임아웃
endWork javadoc clamp 없음 이중 해제가 계수를 음수로 → 작업이 도는 중에 드레인 완료로 보고

네 번째와 LeakTrackingRuntime.closeCount() javadoc("Closing twice is as much a defect as never closing")이 같은 주제를 반대편에서 말한다 — 해제는 정확히 한 번이어야 하고, 0번도 2번도 결함이다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-280 command evidence/raw/280-transport-spi-lifecycle-unimplemented.txt 13개 타입 참조 수, MessagingLifecycle 구현 0(exit=1)·ShutdownPhase 외부 소비자 0(exit=1), 순서 테스트가 실제로 단언하는 것, 배선된 종료 경로와 그 4개 호출 정적 git grep. 어댑터 내부의 pause/flush 수행 여부는 각 leaf가 답함
EVD-279 command ./gradlew :messaging:messaging-transport-spi:test --rerun-tasks BUILD SUCCESSFUL, 24 / 0 / 0 실제 브로커 없음

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 네이티브 클라이언트를 반환하지 않는 이유 — MessagingTransport javadoc
  • 디코딩이 transport 위에서 일어나는 이유 — TransportDelivery javadoc
  • 어댑터가 codec/limit을 고르지 않는 이유 — TransportPublishRequest javadoc
  • 회전이 세대 교체인 이유 — MessagingRuntime javadoc
  • lease가 세대를 pin하는 이유 — MessagingRuntimeLease javadoc
  • 드레인 마감이 필요한 이유, 재시도 금지 이유 — GracefulShutdownCoordinator javadoc
  • 종료 3단계 중 중간 단계를 건너뛰면 생기는 일 — 같은 javadoc
  • 순서 단위별 pause가 필요한 이유 — TransportConsumerRegistration javadoc
  • TransportSettlement을 애플리케이션에 노출하지 않는 이유 — 그 javadoc
  • 네 개의 이전 결함 — §13

추론

  • MessagingLifecycle이 미구현인 것은 MessagingShutdownLifecycle이 Spring SmartLifecycle로 같은 일을 다르게 하기로 했기 때문이다 → 추론. 두 타입의 존재와 후자의 배선은 관측이고, 전자를 버린 결정은 어디에도 기록되지 않았다.
  • current는 lock-free, drainingsynchronized인 이유 → 추론(경합 빈도 차이). 주석 없음.
  • pause sentinel이 둘인 이유 → 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 13개 타입 776줄 전문의 계약과 불변식
  • 24개 테스트가 통과하고 무엇을 단언하는지, 그리고 MessagingLifecycleTest가 enum 선언 순서만 단언한다는 것
  • MessagingLifecycle 구현 0, ShutdownPhase 외부 소비자 0 (둘 다 exit 1로 확인)
  • 실제 배선된 종료 경로(MessagingShutdownLifecycle)가 8단계 중 셋을 명시적으로 수행하고 하나를 Spring 단계에 위임한다는 것
  • 참조 계수·CAS·이중 검사·clamp의 동시성 설계와 그것을 만든 네 개의 이전 결함

확인하지 못한 것

  • 어댑터가 PAUSE_CONSUMERS·FLUSH_SETTLEMENTS·AWAIT_PRODUCER_CONFIRMS를 다른 이름으로 수행하는지. KafkaConsumerRegistrar·RabbitConsumerRegistrarGracefulShutdownCoordinator를 쓰는 것은 확인했으나 그 내부는 각 leaf가 소유한다.
  • 실제 종료에서 이 순서가 지켜지는지. 컨테이너 레인(KafkaBrokerIT, KafkaConsumerSettlementIT)이 있으나 이번 분석에서 실행하지 않았다.
  • MessagingLifecycle을 남겨 둔 것이 의도인지, 미완인지.
  • close()install()이 동시에 일어나는 경우의 실제 빈도. 코드상 창은 존재한다(§4.2).

17. 손볼 것

P2 — 8단계 종료 순서 계약을 구현하는 것이 없고, 그것을 검증한다는 테스트는 enum 선언 순서만 본다
  • 사실. MessagingLifecycle은 8단계 종료 순서를 선언하고 javadoc이 "The order in ShutdownPhase is the contract, not an implementation detail. … Each adapter implements the phases; none of them chooses the order"라고 적는다. 저장소에 구현체가 없고(git grep -E 'implements .*MessagingLifecycle' exit 1), ShutdownPhase의 외부 소비자도 없다(exit 1). MessagingTransport를 구현하는 네 어댑터 중 어느 것도 이 인터페이스를 구현하지 않는다. MessagingLifecycleTest의 다섯 순서 테스트는 전부 List.of(ShutdownPhase.values()).indexOf(A) < indexOf(B) 형태로, 소스에 상수가 적힌 순서를 단언한다.
  • 근거. evidence/raw/280-transport-spi-lifecycle-unimplemented.txt §B·§C.
  • 왜 문제인가. 세 겹이다.
    • 실제 종료는 MessagingShutdownLifecycle(starter)이 하고, 8단계 중 셋만 명시적으로 수행한다(admission 정지 · 새 핸들러 정지 · 드레인). 나머지는 Spring getPhase() 정수와 bean 소멸 순서에 위임되거나 명시 단계가 없다. 순서를 결정하는 것은 ShutdownPhase가 아니다.
    • 테스트 이름과 as(...) 문구가 시스템 동작을 서술한다("flushing before the handlers finish would lose the settlements they produce"). 통과하는 것은 그 동작이 아니라 타이핑 순서다. 이 여섯 테스트는 enum 상수를 재배열하면 실패하고, 재배열해도 시스템은 바뀌지 않는다 — 게이트가 지키는 것과 이름이 어긋난다.
    • 인터페이스는 컴파일 강제를 만들지 않는다. MessagingTransport는 구현 안 하면 빌드가 깨지고, 이것은 아무것도 깨지지 않는다.
  • 확인 방법. evidence/raw/280 재실행. 또는 git grep -n -w MessagingLifecycle -- src → 두 줄(자기 선언, 자기 테스트).
  • 후보. (a) 네 어댑터가 MessagingLifecycle을 구현하고 MessagingShutdownLifecycleshutdown(deadline)을 호출하게 한다. (b) 인터페이스를 제거하고 순서 규칙을 MessagingShutdownLifecycle과 각 registrar의 계약으로 옮긴다. (c) 인터페이스를 "미실현 설계"로 표시하고 테스트가 enum 순서만 본다는 것을 이름과 javadoc에 반영한다.
  • 다음 단계. CASE 후보 + REFERENCE 후보. Case는 "선언된 순서 계약과 실제 종료 경로의 불일치"이고, Reference는 "enum 선언 순서를 단언하는 테스트는 그 순서를 읽는 코드가 있을 때만 게이트다"이다.
P3 — 드레인 마감 30초가 세 곳에서 독립적으로 결정된다
  • 사실. MessagingLifecycle.DEFAULT_DRAIN_DEADLINE(public), DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE(private), MessagingShutdownLifecycle의 생성자 인자.
  • 근거. 세 위치.
  • 왜 문제인가. public 상수가 같은 leaf 안에 있는데 다른 클래스가 자기 private 복사본을 쓴다. MessagingLifecycleTest.theDefaultDrainDeadlineMatchesTheDesign이 public 쪽만 고정하므로 private 쪽이 바뀌어도 통과한다. 그리고 §17 첫 항목대로 public 상수가 있는 인터페이스는 구현체가 없다 — 즉 살아 있는 값(private)이 죽은 인터페이스의 값(public)을 참조하지 않는다.
  • 확인 방법. git grep -n 'DEFAULT_DRAIN_DEADLINE' -- 'src/messaging/**/*.java'
  • 후보. registry가 MessagingLifecycle.DEFAULT_DRAIN_DEADLINE를 참조하거나, 값의 주인을 한 곳으로 정한다.
  • 다음 단계. 첫 항목과 같은 사건의 일부다 → 그 CASE에 MERGED 후보.
P3 — 종료 중 install이 닫히지 않는 창
  • 사실. close()drainingsynchronized로 비우고, currentList.copyOf(current.keySet()) 후 개별 remove한다. 그 사이 install이 새 세대를 넣으면 그 세대는 닫히지 않는다.
  • 근거. DefaultMessagingRuntimeRegistry.java:141-156.
  • 왜 문제인가. 종료 중 회전은 정상 시나리오가 아니므로 실질 위험이 낮다. 다만 이 클래스의 다른 모든 경로가 "정확히 한 번 close"를 CAS로 보장하는 것과 대비되고, 남는 것은 닫히지 않은 브로커 연결이다 — §13의 세 번째 결함과 같은 결과다.
  • 확인 방법. 코드 검토. 테스트로 재현하려면 close()install을 끼워 넣어야 한다.
  • 후보. close()에 종료 플래그를 두고 install이 그 이후에는 즉시 runtime.close()하도록 한다.
  • 다음 단계. REFERENCE 후보(멱등 종료를 보장하는 컴포넌트는 종료 이후의 등록도 정의한다).
P3 — pause scope sentinel이 두 인터페이스에서 다르다
  • 사실. TransportConsumerRegistration.pause는 빈 문자열이 전체, PauseResumeController.pause(core-api)는 "*"가 전체.
  • 근거. 두 javadoc.
  • 왜 문제인가. PauseResumeController가 소비자 0이므로 오늘 충돌하지 않는다. 그것을 배선하려는 사람이 변환을 넣어야 하고, 빠뜨리면 "*"가 이름이 "*"인 파티션을 가리키게 된다 — 실패하지 않고 아무것도 일시정지하지 않는다.
  • 확인 방법. 두 javadoc 대조.
  • 후보. sentinel을 통일하거나 Optional<String>으로 바꾼다.
  • 다음 단계. REFERENCE 후보(같은 개념의 sentinel은 계층을 넘어 하나로 정한다).
확인된 설계(문제 아님)
  • 네이티브 클라이언트를 반환하지 않는 SPI 경계
  • 인코딩/디코딩을 transport 밖에 두어 실패 분류를 플랫폼이 소유하는 것
  • 세대 교체 + 참조 계수 + CAS로 "정확히 한 번 close"를 보장하는 것과, 그것을 20세대 회전으로 확인하는 테스트
  • computeIfPresent로 조회-증가를 원자화한 것
  • tryBeginWork의 이중 검사와 endWork의 clamp
  • 마감 도달 작업을 정산하지 않고 버려 브로커가 재전달하게 하는 것
  • 세대별 retiredAt과 닫힌 세대의 목록 제거

Source anchors

id kind path revision what it proves limitations
MTS-001 registry src/config/architecture/modules.json 21234e38 deps 3개, memberships ["app-bootstrap"] 선언
MTS-002 build messaging-transport-spi/build.gradle same 벤더 의존성 0, 세 project 의존이 전부 api
MTS-003 code .../transport/MessagingTransport.java same SPI 경계와 네이티브 미노출
MTS-004 code .../transport/DefaultMessagingRuntimeRegistry.java 전문 same §4.2 동시성 설계 전부와 세 개의 이전 결함 종료 중 install 창(§17)
MTS-005 code .../transport/GracefulShutdownCoordinator.java 전문 same §4.3 드레인 계약과 clamp 결함 이력
MTS-006 code .../transport/MessagingLifecycle.java same 8단계 선언과 "order is the contract" 진술 구현 없음(§12.1)
MTS-007 code .../transport/Transport*.java (6) same 발행·수신·정산 계약
MTS-008 test MessagingRuntimeRegistryTest (9) same 세대 관리 실제 브로커 없음
MTS-009 test ResourceLeakGateTest (4) same 20세대 회전, 누수 lease 강제 종료, 정확히 한 번 close 며칠 단위 아님
MTS-010 test GracefulShutdownTest (5) same 드레인 경계 29/30초, 이중 endWork clamp
MTS-011 test MessagingLifecycleTest (6) same enum 선언 순서와 상수 값만 종료 동작 미증명(§10.2)
MTS-012 cross-leaf code messaging-spring-boot-starter/.../MessagingShutdownLifecycle.java 전문 same 실제 배선된 종료 경로와 그것이 수행하는 3단계, getPhase() 위임 해당 leaf SSOT가 소유
MTS-013 cross-leaf code 4개 *MessagingTransport.java same SPI 구현 넷, MessagingLifecycle 구현 0 각 leaf SSOT가 소유
EVD-280 command evidence/raw/280-transport-spi-lifecycle-unimplemented.txt same §12.1 전부, exit code 포함 정적 검색
EVD-279 command ./gradlew :messaging:messaging-transport-spi:test --rerun-tasks same 24 / 0 / 0 브로커 없음

A20-GRPC-ADMIN. grpc-admin

분석 중에는 grpc/GRPC-ADMIN.md 파일이었다. 253줄.

grpc-admin 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 12파일 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-admin SSOT owner: grpc-admin integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • allowed_dependencies: ["grpc-core-api", "grpc-server"]
  • runtime_memberships: [] — build-only
파일 LOC
GrpcDrainCoordinator 162
GrpcServiceHealthRegistry 154
GrpcPlatformSnapshotService 115
GrpcReflectionPolicy 74
GrpcPlatformSnapshot · GrpcAdminExposurePolicy 71 · 71
GrpcHealthPolicy · GrpcDrainResult · GrpcDrainPolicy 49 · 48 · 43
GrpcHealthState · GrpcReflectionMode 38 · 32
GrpcReflectionAccessDecision · GrpcDrainPhase 28 · 28
test 4파일 490
Coverage ledger
scope count disposition reason
main/java/** 12 FULL_READ 전 본문 축자 확인
test/java/** 4 FULL_READ 490줄
build.gradle 1 FULL_READ 8줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-4
// Operational surface: the standard health registry, the reflection exposure policy, the drain
// coordinator, and the secret-free runtime policy snapshot an administrator reads.

2. 건강 레지스트리 — 낙관에서 시작하지 않는다

모든 등록 서비스가 UNKNOWN 에서 시작한다.

"A registry that starts optimistic reports ready during startup, receives traffic before the first dependency check has run, and fails the requests that arrive in that window — the window being exactly the moment a rollout is shifting traffic onto the instance."

그리고 배수 중에는 markServing·markNotServing 이 무시된다.

"a service that reports itself healthy after the drain has started would be routed traffic the instance has already promised not to take."

전역 상태 계산은 세 단계다 — 임계 의존이 하나라도 불건강하면 NOT_SERVING, 아니면 하나라도 NOT_SERVING 이면 NOT_SERVING, 하나라도 SERVING 이면 SERVING, 그 밖에는 UNKNOWN.

3. 배수 순서

READINESS_FALSE → HEALTH_DRAINING → REJECT_NEW_ADMISSION → DRAIN_UNARY → SIGNAL_STREAMS → FORCE_CANCEL

beginDrain 이 앞의 둘을 한 번에 수행하고, 그 전에 rejectNewAdmission 을 부르면 던진다.

"refusing calls before readiness has flipped produces errors for traffic that routing is still sending"

조정자는 잠들지 않는다.

"It is given the current moment and the counts, and returns whether the phase is done; the waiting belongs to the caller, which is what makes every branch of this testable without a clock."

예산은 누적이다 — 스트림 신호 완료 판정이 unaryDrainBudget + streamSignalBudget 을 기준으로 한다.

4. 두 게이트 규칙이 세 곳에 같은 형태로 있다

타입 두 게이트
GrpcReflectionPolicy(ADMIN_ONLY) 관리 네트워크 + 관리 역할
GrpcAdminExposurePolicy
GrpcChannelDiagnosticsPolicy(grpc-advanced-diagnostics)

"a role check alone lets an admin credential leaked to the public network enumerate the schema, and a network check alone lets anyone who reaches the admin network do it."

그리고 반사 가시성과 메서드 인가를 분리한다.

"A method that reflection reveals is not thereby callable, and a method reflection hides is not thereby protected. Conflating the two produces a schema treated as a secret and an authorization check nobody wrote."

5. 스냅숏

권한이 없으면 편집본이 아니라 빈 값을 돌려준다.

"Empty rather than a redacted snapshot: a partial answer tells an unauthorized caller which services exist."

내용이 아니라 해시를 싣는다. 그리고 판본과 시각을 필수로 요구한다 — 사고 중의 질문은 "무엇이 도는가" 가 아니라 "무엇이 바뀌었는가" 다.

driftAgainstRelease 가 양방향을 본다 — 릴리스에 있고 인스턴스에 없는 채널, 인스턴스에 있고 릴리스에 없는 채널을 모두 보고한다.

10. 테스트 레인

네 테스트 490줄. 배수 순서와 예산, 건강 전이, 반사 결정, 스냅숏 게이트와 표류를 확인한다.

12. negative-space probes

12.1 도달성. build-only. 이 리프의 타입 중 셋(GrpcServiceHealthRegistry·GrpcReflectionPolicy·GrpcAdminExposurePolicy·GrpcDrainPolicy)은 grpc-spring-boot-starter 자동 설정이 빈으로 만든다. GrpcDrainCoordinator·GrpcPlatformSnapshotService 는 만들지 않는다.

12.2 대조군. 비밀 필드 패턴이 두 리프에 따로 있다 — 이쪽의 SECRET_FIELD(9종)와 grpc-advanced-diagnosticsSENSITIVE_FIELD(10종). 겹치지만 같지 않다. 이쪽에는 api[_-]?key·passphrase 가 있고 저쪽에는 payload·metadata·trace_id 가 있다. 두 표면이 다르므로 목록이 다른 것 자체는 합리적이다.

12.4 드리프트. build.gradle 이 서술한 네 요소가 전부 존재한다. 드리프트 없음.

16. 확인하지 못한 것

  • 실제 서버를 띄워 배수를 돌리지 않았다. 배선 경로가 없다.
  • GrpcAdmissionController 를 배수 중에 호출해 §17.1 을 재현하지 않았다. 두 클래스의 공개 표면으로 판정했다.
  • §17.4 의 교차를 실행으로 재현하지 않았다. markServing 의 확인과 실행이 분리되어 있고 beginDraining 의 두 문장 사이에 창이 있다는 것으로 판정했다.

17. 손볼 것

17.1 P2 — rejectNewAdmission() 이 단계만 기록하고 아무것도 거절하지 않는다
public void rejectNewAdmission() {
  requireStarted();
  phasesRun.add(GrpcDrainPhase.REJECT_NEW_ADMISSION);
}

public boolean admittingNewCalls() {
  return !phasesRun.contains(GrpcDrainPhase.REJECT_NEW_ADMISSION);
}

javadoc 은 "Starts refusing new calls" 라고 적는다. 실제로 하는 일은 단계 목록에 표식을 넣는 것뿐이다.

조정자는 GrpcAdmissionController 를 협력자로 들고 있는데, 그것을 쓰는 곳은 inFlightAdmitted() 의 조회 하나다.

그리고 승인 제어기의 공개 표면에 승인을 멈추는 메서드가 없다.

GrpcAdmissionController — tryAdmit · promoteFromQueue · release · inFlight · queued · rejected

close·drain·refuseNew 에 해당하는 것이 없다. 그러므로 배수가 시작된 뒤에도 tryAdmit() 은 용량이 남아 있는 한 계속 승인한다.

admittingNewCalls() 은 그 사실과 무관하게 거짓을 돌려준다 — 표식을 읽기 때문이다. 운영자나 상위 코드가 이 값을 보고 "더 이상 받지 않는다" 고 읽으면 틀린 답을 얻는다.

테스트가 이것을 볼 수 없다. 배수 테스트가 단언하는 것은 admittingNewCalls() 의 값이고, 단계 이후에 tryAdmit() 이 거절되는지는 어느 테스트도 묻지 않는다.

수정. 승인 제어기에 승인 중단 상태를 두고(stopAdmitting() 과 그것을 보는 tryAdmit), 조정자의 rejectNewAdmission 이 그것을 부르게 한다. 지금 형태에서는 배수 순서를 지키는 장치가 순서 표식만 갖고 있다.

17.2 P3 — 비밀 필드 검사가 스냅숏의 네 구획 중 하나에만 적용된다
List<String> forbidden = GrpcAdminExposurePolicy.forbiddenFields(channelProfileHashes);
if (!forbidden.isEmpty()) {
  throw new IllegalArgumentException("a platform snapshot must not carry " + forbidden + "; hashes and names only");
}

메시지는 "a platform snapshot must not carry …" 로 스냅숏 전체를 말한다. 검사 대상은 channelProfileHashes 하나다.

같은 채널 이름 공간을 쓰는 두 맵이 더 있다 — resolverAndLoadBalancerByChannel, retryOwnerByChannel. 그리고 registeredServices 목록과 serviceHealth 맵이 있다. 어느 것도 검사되지 않는다.

세 맵의 키 집합이 같아야 한다는 요구가 없으므로, 어떤 채널이 나머지 두 맵에만 있으면 그 이름은 검사를 지나지 않는다.

grpc-advanced-diagnostics 의 스냅숏은 같은 형태의 자기 검사를 두 구획(주소 목록, 자원 판본 키)에 적용한다. 두 리프의 규율이 갈린다.

수정은 네 구획 전부를 같은 검사에 넣는 것이다. 값이 아니라 키를 보는 검사이므로 비용이 낮다.

17.3 P3 — 배수 조정자가 가변이고 동기화가 없다

phasesRun(ArrayList), startedAt, completedUnaryCalls, signalledStreams 가 평범한 필드다. synchronized·volatile·동시 자료구조가 없다.

같은 리프의 건강 레지스트리는 정반대다 — ConcurrentHashMap 둘과 volatile boolean draining. 즉 이 리프는 동시성을 인지하고 있고 한 클래스에만 적용했다.

조정자의 javadoc 이 대기를 호출자에게 맡긴다고 적으므로 단일 호출자 전제로 읽을 수 있다. 다만 그 전제가 자바독에 적혀 있지 않고, unaryDrainComplete 는 반복 호출을 전제한 형태라 종료 훅과 상태 조회가 다른 스레드에서 닿기 쉽다.

수정은 단일 스레드 전제를 자바독에 적거나, 형제 클래스와 같은 수준으로 맞추는 것이다.

17.4 P2 — 배수 시작이 확인 후 실행이라, 배수 중에 한 서비스가 다시 SERVING 이 될 수 있다

§17.3 은 조정자가 동기화 없이 가변이고 건강 레지스트리는 "정반대" 라고 적었다. 레지스트리 쪽을 다시 읽으면 자료구조는 정반대이지만 규율은 같은 자리에서 깨진다.

public void markServing(String serviceName) {
  requireServiceName(serviceName);
  if (draining) { return; }                       // ← 확인
  states.put(serviceName, GrpcHealthState.SERVING);  // ← 실행
  recomputeGlobal();
}

public void beginDraining() {
  draining = true;                                             // ①
  states.replaceAll((service, state) -> GrpcHealthState.DRAINING);   // ②
}

drainingvolatile 이므로 가시성은 문제가 아니다. 문제는 순서다. 건강 검사 스레드가 ① 이전에 if (draining) 을 통과하고 ② 이후에 states.put(..., SERVING) 을 실행하면, 그 서비스는 배수 중에 SERVING 으로 남는다. 그리고 같은 호출이 이어서 recomputeGlobal() 을 부르므로 전역 상태까지 SERVING 으로 돌아간다anyNotServing 이 거짓이고 anyServing 이 참이기 때문이다.

그러면 ready() 가 참을 답하고, 로드밸런서는 이 인스턴스로 다시 트래픽을 보낸다. 이 클래스의 javadoc 이 막겠다고 한 것이 정확히 그것이다.

"Ignored while draining: a service that reports itself healthy after the drain has started would be routed traffic the instance has already promised not to take."

창은 좁다. 건강 검사는 주기적이고 배수는 한 번이므로, 겹치려면 검사가 배수 시작을 가로질러야 한다. 그리고 겹치는 순간이 정확히 롤아웃 중 — 즉 트래픽이 옮겨지는 중 — 이라는 것이 이 가족의 다른 자리에서 반복해서 나오는 논거다(§17.1 의 "readiness 를 먼저" 도 같은 창을 다룬다).

recordDependencyHealth 도 같은 형태다 — dependencyHealth.put(...) 뒤에 if (!draining) recomputeGlobal(); 을 부르므로, 같은 교차에서 전역을 되살릴 수 있다.

시험이 보지 못하는 이유. 레지스트리 시험은 단일 스레드이고, beginDraining() 뒤에 markServing 을 부르는 사례는 순차적으로만 확인한다 — 그 경로에서는 가드가 정확히 작동한다.

수정. 상태 전이를 하나의 원자 연산으로 만든다 — states.computeIfPresent(service, (k, v) -> draining ? v : SERVING) 처럼 draining 을 맵 연산 안에서 읽거나, beginDrainingreplaceAll 을 마친 뒤 한 번 더 replaceAll 을 돌려 늦게 들어온 쓰기를 덮는다. 후자는 창을 좁힐 뿐이므로 전자가 맞다.

등급. GrpcServiceHealthRegistrygrpc-spring-boot-starter 가 빈으로 만드는 셋 중 하나다(§12.1). 다만 beginDraining() 을 부르는 production 코드는 GrpcDrainCoordinator 이고 그것은 조립되지 않으므로, 오늘 이 창이 열리지는 않는다. 배수를 배선하는 날 §17.1 과 함께 봐야 한다.

확인된 설계(문제 아님)
  • 모든 서비스를 UNKNOWN 에서 시작하는 것과 그 근거.
  • 배수 중 건강 보고를 무시하는 것.
  • 읽기 준비 해제를 승인 거절보다 먼저 두고, 순서를 어기면 던지는 것.
  • 조정자가 잠들지 않고 시각과 개수를 인자로 받는 것 — 모든 분기가 시계 없이 검증 가능하다.
  • 두 게이트 규칙을 세 정책에 같은 형태로 둔 것.
  • 반사 가시성과 메서드 인가를 분리한 것.
  • 권한 없는 호출에 편집본이 아니라 빈 값을 주는 것.
  • 스냅숏이 내용이 아니라 해시를 싣고, 판본과 시각을 요구하는 것.
  • 표류 비교가 양방향인 것.

Source anchors

src/grpc/grpc-admin/build.gradle:1-8
main/java/…/admin/GrpcDrainCoordinator.java:1-162
main/java/…/admin/GrpcServiceHealthRegistry.java:1-154
main/java/…/admin/GrpcPlatformSnapshotService.java:1-115
main/java/…/admin/GrpcReflectionPolicy.java:1-74
main/java/…/admin/GrpcPlatformSnapshot.java:1-71
main/java/…/admin/GrpcAdminExposurePolicy.java:1-71
main/java/…/admin/(GrpcHealthPolicy · GrpcDrainResult · GrpcDrainPolicy · GrpcHealthState · GrpcReflectionMode · GrpcReflectionAccessDecision · GrpcDrainPhase)
test/java/…/admin/(GrpcDrainCoordinatorTest · GrpcPlatformSnapshotServiceTest · GrpcServiceHealthRegistryTest · GrpcReflectionPolicyTest)
src/grpc/grpc-server/…/server/GrpcAdmissionController.java (공개 표면 대조)

A20-GRPC-ADVANCED-BOOTSTRAP. grpc-advanced-bootstrap

분석 중에는 grpc/GRPC-ADVANCED-BOOTSTRAP.md 파일이었다. 359줄.

grpc-advanced-bootstrap 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 9파일 610줄, test 2파일 279줄 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc-advanced/grpc-advanced-bootstrap SSOT owner: grpc-advanced-bootstrap integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • allowed_dependencies: ["grpc-core-api"] — Advanced 는 Stable 공개 타입에 의존하고 그 반대는 없다
  • runtime_memberships: [] — build-only
파일 LOC 패키지
GrpcAdvancedFeatureFlags 105 bootstrap
GrpcAdvancedModuleGuard 84 bootstrap
GrpcAdvancedCapability 65 bootstrap
GrpcAdvancedCapabilityDisabledException 42 bootstrap
GrpcCapabilityGrade 38 bootstrap
GrpcAdvancedPromotionGate 85 release
GrpcAdvancedPromotionEvidence 75 release
GrpcAdvancedSupportMatrix 66 release
GrpcAdvancedPromotionDecision 50 release
main 합계 610
Coverage ledger
scope count disposition reason
main/java/** 9 FULL_READ 610줄 전 본문
test/java/** 2 FULL_READ 279줄(150+129) · 테스트 17개
build.gradle 1 FULL_READ 12줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-9
// The Advanced boundary itself: capability grades, the `ca-skeleton.grpc.advanced.*` feature-flag
// contract, the module guard that refuses an unflagged capability, and the per-capability
// promotion gate.
// This leaf depends on Stable public types and never the other way round.

2. 능력 15종과 등급 4종

능력을 하나씩 등급 매기는 것이 설계다.

"Bundling them under one 'advanced' flag makes enabling gRPC-Web — a compatibility bridge with a proxy in front of it — the same decision as enabling xDS, which brings a control plane and its outage modes. They are not the same decision, and a single switch is how the second one gets made by accident."

등급 시작 가능 production 별도 승인
ADVANCED_STABLE 아니오
EXPERIMENTAL
WATCH 아니오
DISABLED 아니오

기본 등급 분포는 ADVANCED_STABLE 11, EXPERIMENTAL 3(HEDGING·CUSTOM_LOAD_BALANCER·XDS), WATCH 1(EDITION_2026)이다.

EXPERIMENTAL 에 두 번째 승인을 요구하는 근거가 적혀 있다.

"The flag says somebody wanted the feature; the approval says somebody accepted that its failure modes are not fully characterised, which is a different person's decision on most teams."

3. 게이트가 세 조건을 순서대로 본다

if (!flags.flagSet(capability))                   "its feature flag is not set"
if (!grade.startable())                           "it is graded WATCH, which cannot start"
if (production && requiresApproval && !approved)  "production needs a separate approval"

"Collapsing them into one boolean produces a 'not enabled' message for three situations with three different remedies."

requireStableStarterIsClean 이 같은 불변식을 런타임에서도 확인한다 — 팻 자, 셰이드 산출물, 테스트 하네스처럼 다른 방식으로 조립된 런타임을 위해서다. 다만 그 메서드를 부르는 런타임이 없다(§12.1). 지금 그 검사를 실행하는 것은 이 리프의 자기 테스트뿐이다.

4. 승격 게이트

증거는 능력마다 따로 기록된다.

"a shared record makes promoting one of them promote whichever others happened to be measured at the same time."

일곱 항목(호환성·보안 검토·고장·성능·ADR·런북·실환경 테스트)과 담금 기간을 본다. 임계값이 둘이다.

ADVANCED_STABLE_SOAK = 7일
STABLE_DEFAULT_SOAK  = 30일

"becoming a Stable default means every deployment gets it, which additionally puts its dependencies on every classpath and its failure modes in every on-call rotation."

그리고 WATCHEXPERIMENTAL 을 먼저 거쳐야 한다.

GrpcAdvancedSupportMatrix.apply 는 결정의 시작 등급이 현재 등급과 다르면 거부한다 — 두 승격이 경합했거나 하나가 재생된 경우다.

10. 테스트 레인

두 테스트 279줄, 17개 테스트. 게이트의 세 조건, 능력별 개별 깃발, WATCH 거부, production 이중 승인, 활성 집합, 승격 임계값 둘, WATCH 선행 규칙, 매트릭스 경합 거부를 확인한다.

capabilitiesDraggedAlong 이 항상 빈 목록을 돌려주고, 그 메서드가 존재하는 이유를 javadoc 이 적는다 — "the method exists so a test can assert that rather than a comment claiming it". 실제로 그 테스트가 있다.

12. negative-space probes

12.1 도달성. Advanced 가족 전체가 배선되지 않는다. 리프 안에서도 절반만 쓰인다.

의존 선언은 다섯이다 — grpc-advanced-diagnostics · -streaming · -compat · -edition · -resilience 가 각각 api project(':grpc-advanced:grpc-advanced-bootstrap').

그중 실제로 타입을 부르는 것은 둘뿐이다.

호출 지점 무엇을
GrpcChannelDiagnosticsPolicy:47,53 GrpcAdvancedModuleGuard.available(flags, CHANNEL_DIAGNOSTICS) · …(flags, XDS)
GrpcXdsStartupGuard:33 GrpcAdvancedModuleGuard.available(flags, XDS)

-streaming · -compat · -edition 은 의존만 선언하고 참조가 없다.

그리고 리프 밖에서 불리는 것은 available 하나뿐이다.

  • GrpcAdvancedModuleGuard.require(...) — 던지는 형태. 외부 호출자 0. 세 갈래 거부 메시지 전체가 자기 테스트에서만 실행된다. GrpcAdvancedCapabilityDisabledException 도 마찬가지다.
  • requireStableStarterIsClean(...) — 외부 호출자 0. javadoc 이 겨냥한 "다르게 조립된 런타임"이 이 검사를 부르지 않는다.
  • advancedModules() — 외부 호출자 0.
  • release 패키지 4파일 276줄 전체 — 리프 밖 참조 0. 승격 게이트·증거·결정·지원 매트릭스를 만드는 곳이 자기 테스트 말고 없다.

즉 이 리프에서 실행 경로에 걸려 있는 것은 GrpcAdvancedCapability · GrpcCapabilityGrade · GrpcAdvancedFeatureFlags · GrpcAdvancedModuleGuard.available 네 조각이고, 나머지 절반은 선언이다.

12.2 대조군. 능력을 하나씩 등급 매기는 형태가 messaging 의 CompatibilityMatrix(어댑터별 STABLE/EXPERIMENTAL/EXTENSION)와 같은 계열이다. 차이는 이쪽이 시작 가능 여부와 production 승인 요구를 등급 자체의 속성으로 둔 점이다.

12.4 드리프트. build.gradle 이 서술한 네 요소(등급·깃발 계약·모듈 가드·승격 게이트)가 전부 존재한다. 드리프트 없음.

16. 확인하지 못한 것

  • verifyCleanArchitectureDependencies 를 이 리비전에서 실행하지 않았다.
  • 등급 재정의로 WATCH 능력을 켜는 것을 실행으로 재현하지 않았다(§17.1). 코드 경로로 판정했다.
  • 테스트를 실행하지 않았다. 17개 전부 본문으로만 확인했다. §17.4 의 담금 역전도 evaluate 본문과 두 테스트가 고른 숫자(60일 · DISABLED)로 판정한 것이다.
  • -streaming · -compat · -edition 이 이 리프를 의존만 하고 쓰지 않는다는 것은 타입 이름 grep 으로 판정했다. 각 리프 SSOT 에서 다시 본다.

17. 손볼 것

17.1 P3 — 등급 재정의에 하한이 없어 "켤 수 없다" 는 등급이 켜질 수 있다

GrpcCapabilityGrade 의 javadoc 이 두 등급을 단정한다.

WATCH     — "Tracked, not implemented. Cannot be enabled."
DISABLED  — "Withdrawn or refused. Cannot be enabled."

그런데 등급은 런타임에 갈아끼울 수 있다.

public GrpcAdvancedFeatureFlags withGrade(GrpcAdvancedCapability capability, GrpcCapabilityGrade grade) {
  grades.put(capability, grade);
  return this;
}

withGrade(EDITION_2026, ADVANCED_STABLE).enable(EDITION_2026) 이면 가드의 두 번째 조건이 통과한다.

등급 올리기 자체는 의도된 기능이다 — 테스트 a deployment may raise a capability's grade on its own evidenceHEDGING(EXPERIMENTAL)을 ADVANCED_STABLE 로 올린다. 문제는 그 재정의에 하한이 없다는 것이다.

  • EXPERIMENTAL 을 올리는 것은 "실패 양식이 충분히 규명되지 않은 것을 감수한다" 는 판단이고 배포가 자기 증거로 내릴 수 있다.
  • WATCH 를 올리는 것은 다르다. 그 등급의 뜻이 "추적할 뿐 구현되지 않았다" 이므로 배포가 가질 자기 증거가 없다.

그리고 승격 게이트는 WATCHEXPERIMENTAL 을 먼저 거쳐야 한다는 규칙을 갖는데, 런타임 재정의는 그 게이트를 지나지 않는다. 같은 리프 안에 문이 둘이고 증거 규칙은 한쪽에만 있다.

수정은 withGrade 가 현재 등급이 startable() 인 능력에만 적용되게 하거나, WATCH·DISABLED 에서 올리는 재정의를 거부하는 것이다.

17.2 P3 — 승격 게이트가 하향 전이도 승격 규칙으로 판정하고, javadoc 이 약속한 거부는 없다
/** @throws IllegalArgumentException when the transition is not one this gate governs */
public static GrpcAdvancedPromotionDecision evaluate(
    GrpcAdvancedPromotionEvidence evidence, GrpcCapabilityGrade from, GrpcCapabilityGrade to) {
  
  if (from == to) { throw new IllegalArgumentException("a promotion changes the grade"); }

던지는 경우는 널과 from == to 둘뿐이다. "이 게이트가 다루는 전이가 아닐 때" 라는 조건에 해당하는 검사가 없다.

그래서 하향 전이가 승격 규칙으로 판정된다.

evaluate(none(XDS), ADVANCED_STABLE, DISABLED)
  → to != ADVANCED_STABLE 이므로 requiredSoak = 30일
  → 증거 일곱 항목 부재 + 담금 부족으로 blockers 여덟
  → 결정: 거부

능력을 철회하려는 결정이 증거 부족을 이유로 막힌다. 방향이 뒤집혀 있다.

지금은 도달성이 낮다 — 이 게이트를 부르는 production 코드가 없고 테스트도 상향 전이만 넣는다. 기록하는 이유는 javadoc 이 그 거부를 이미 약속했다는 점이다.

수정은 to.ordinal() 이 아니라 등급의 서열을 명시한 뒤 상향 전이만 받고 나머지는 던지는 것이다. 철회는 별도 경로가 필요하다.

17.3 P3 — 깃발 홀더가 가변이고 동기화가 없다

GrpcAdvancedFeatureFlags 는 두 EnumMapenable·withGrade 로 갱신하고, available·active 가 같은 맵을 읽는다. synchronized·volatile·동시 자료구조가 없다.

시작 시 전부 설정하고 그 뒤로 읽기만 한다면 안전 공개 문제만 남는다. 다만 두 메서드가 this 를 돌려주는 유창한 형태라 런타임 중 갱신을 권하는 모양이고, active() 는 순회 중 갱신에 노출된다.

같은 저장소가 이 형태를 다른 리프에서 결함으로 기록했다(GrpcCompletionReconciler 의 동기화 없는 ArrayList). 여기서는 등급과 깃발이 요청 경로에서 읽히므로 같은 노출이 생길 수 있다.

수정은 홀더를 불변으로 만들고 enable·withGrade 가 새 인스턴스를 돌려주게 하는 것이다. 이 저장소가 다른 곳에서 쓰는 형태다(GrpcProtoStyleManifest.allowingWellKnownTypes 등).

17.4 P2 — 30일 담금이 열거형에 없는 등급을 위해 쓰였고, 그 결과 WATCH → EXPERIMENTAL→ ADVANCED_STABLE 보다 어렵다

GrpcAdvancedPromotionGate javadoc 의 모형은 등급 둘이다.

"Reaching Advanced Stable means the capability works and is documented; becoming a Stable default means every deployment gets it… The second needs the first plus a longer soak."

그리고 상수도 둘이다.

public static final Duration ADVANCED_STABLE_SOAK = Duration.ofDays(7);
public static final Duration STABLE_DEFAULT_SOAK  = Duration.ofDays(30);

그런데 GrpcCapabilityGrade 의 값은 ADVANCED_STABLE · EXPERIMENTAL · WATCH · DISABLED 넷이다. "Stable default" 라는 등급이 없다.

선택은 이렇게 적혀 있다.

Duration requiredSoak =
    to == GrpcCapabilityGrade.ADVANCED_STABLE ? ADVANCED_STABLE_SOAK : STABLE_DEFAULT_SOAK;

ADVANCED_STABLE 이 아닌 나머지 전부가 30일 갈래로 떨어진다 — EXPERIMENTAL, WATCH, DISABLED. 존재하지 않는 등급을 위해 만든 갈래가 존재하는 세 등급을 삼켰다.

따라오는 역전. missing() 검사도 목표 등급과 무관하게 일곱 항목을 전부 요구한다. 그래서:

전이 필요한 증거 필요한 담금
EXPERIMENTAL → ADVANCED_STABLE 일곱 전부 7일
WATCH → EXPERIMENTAL 일곱 전부 30일

WATCH 능력이 밟도록 강제된 유일한 첫 걸음이(같은 메서드의 셋째 blocker: "it becomes EXPERIMENTAL before anything else") 상위 등급보다 엄격하다. 그리고 WATCH 의 뜻은 "추적할 뿐 구현되지 않았다" 이므로, 정의상 담금 기록이 가장 적은 등급에 가장 긴 담금을 요구한다.

테스트가 이 뒤틀림을 그대로 보여 준다.

@DisplayName("becoming a Stable default needs a longer soak than becoming Advanced Stable")
void theStableDefaultThresholdIsHigher() {
  
  GrpcAdvancedPromotionGate.evaluate(weekLongSoak,
      GrpcCapabilityGrade.ADVANCED_STABLE, GrpcCapabilityGrade.DISABLED)   // ← 철회 전이
      .blockers()   .contains("requires 30");
}

30일 갈래를 실행하려고 고른 전이가 ADVANCED_STABLE → DISABLED, 즉 철회다. 이름은 "Stable default 가 되는 것"이라고 말한다. 겨냥한 등급이 열거형에 없으니 그것을 밟을 방법이 없었고, 남은 것 중 아무거나 골라야 했다는 흔적이다.

그리고 WATCH → EXPERIMENTAL 을 확인하는 테스트는 담금을 60일로 준다.

GrpcAdvancedPromotionEvidence.complete(EDITION_2026, Duration.ofDays(60))

7일로 줬다면 통과하지 않는다. 30일 요구가 레인에 걸리지 않는 이유가 이 숫자 선택이다.

§17.2 와의 관계. §17.2 는 이 갈래의 증상 하나(철회 전이가 승격 규칙으로 판정되는 것)를 기록했다. 원인은 목표 등급별 요구 사항이 없다는 것이고, 그래서 상향 전이 안에서도 순서가 뒤집혔다.

수정. 목표 등급마다 요구 사항을 명시한다.

record Requirement(Set<String> evidence, Duration soak) {}
static Requirement requirementFor(GrpcCapabilityGrade to) {  }   // EXPERIMENTAL 은 더 얕게

STABLE_DEFAULT 를 실제로 표현하려면 등급으로 추가하거나(그러면 GrpcAdvancedCapability 를 떠나 Stable 기본값이 된다는 뜻이므로 별도 개념이 맞다) 이 게이트가 다루지 않는다고 적고 상수를 지운다. 지금은 이름만 있고 대상이 없다.

17.5 P3 — capabilitiesDraggedAlong 은 독립성을 증명하지 않는다. 상수를 상수와 비교한다
/** Always empty, and the method exists so a test can assert that rather than a comment claiming it */
public static List<GrpcAdvancedCapability> capabilitiesDraggedAlong(GrpcAdvancedCapability promoted) {
  if (promoted == null) throw ;
  return List.of();
}

javadoc 이 스스로 밝히듯 본문은 무조건 빈 목록이다. 그것을 단언하는 테스트는 리터럴이 리터럴임을 확인한다 — 증거를 능력마다 따로 기록했다는 §4 의 설계 속성과는 아무 연결이 없다. 설계가 무너져 apply 가 다른 능력의 등급을 바꾸게 되어도 이 메서드는 여전히 빈 목록을 돌려준다.

진짜 증거는 같은 테스트의 다른 줄에 있다.

matrix.apply(evaluate(complete(HEDGING, 7일), EXPERIMENTAL, ADVANCED_STABLE));
assertThat(matrix.gradeOf(HEDGING)).isEqualTo(ADVANCED_STABLE);
assertThat(matrix.gradeOf(GRPC_WEB)).isEqualTo(webBefore);   // ← 이 줄이 독립성을 붙든다

승격을 실제로 적용하고 다른 능력의 등급이 그대로임을 확인한다. 이쪽은 설계가 무너지면 깨진다.

이 문서의 이전 판정을 고친다. 앞선 판에서 이 메서드를 "주석이 주장하는 대신 테스트가 붙든다"는 확인된 설계로 분류했다. 다시 읽으니 붙드는 것은 옆줄이고, 이 메서드는 그 옆줄이 있다는 사실을 가린다.

수정. 메서드를 지우고 단언을 매트릭스 비교 쪽으로 남긴다. 남겨 둔다면 실제로 매트릭스를 훑어 등급이 바뀐 다른 능력을 돌려주게 만든다 — 그때 비로소 이름이 하는 말과 본문이 맞는다.

17.6 P3 — 예외가 들고 있는 능력이 transient 라 역직렬화 뒤 사라진다
public class GrpcAdvancedCapabilityDisabledException extends RuntimeException {
  private static final long serialVersionUID = 1L;
  private final transient GrpcAdvancedCapability capability;
  
  public GrpcAdvancedCapability capability() { return capability; }

transient 는 보통 직렬화 가능하지 않은 필드를 담은 Serializable 클래스에 대한 정적 분석 경고를 끄려고 붙인다. 그런데 열거형은 언제나 직렬화 가능하다 — 여기서 transient 가 막을 문제가 애초에 없다.

대가는 있다. 예외가 직렬화를 거쳐 오면 capability()null 이다. 메시지 문자열은 살아남으므로 사람이 읽는 데는 지장이 없고, 그래서 눈에 띄지 않는다.

등급. 이 예외를 던지는 require 자체가 리프 밖에서 불리지 않으므로(§12.1) 오늘 도달하지 않는다. transient 를 지우는 것이 수정 전부다.

확인된 설계(문제 아님)
  • 능력별 개별 등급과 개별 깃발 — 하나의 스위치가 두 번째 결정을 사고로 만들지 않는다.
  • EXPERIMENTAL 의 production 이중 승인 — 원하는 사람과 감수하는 사람이 다르다.
  • 가드가 세 조건을 순서대로 보고 각각 다른 메시지를 내는 것.
  • 증거를 능력마다 따로 기록한 것 — 공유 기록은 하나의 승격이 다른 것을 함께 올린다.
  • 두 담금 임계값 — 한 배포에서 일주일 돈 것과 모든 배포에 나가는 것은 같은 주장이 아니다.
  • 매트릭스가 시작 등급 불일치를 거부하는 것 — 경합과 재생을 구분해 준다.
  • 빈 컬렉션에 대한 EnumSet.copyOf 함정을 삼항으로 피한 것.
  • GrpcAdvancedSupportMatrix.apply 가 결정의 from 을 현재 등급과 대조하는 것 — 이 저장소가 같은 문제를 반대로 푼 자리가 있어서 대비된다. grpc-codegenGrpcSchemaArtifactPublisher.publish(candidate, decision) 는 결정이 어느 후보를 판정한 것인지 확인하지 않아 짝이 어긋날 수 있다(그쪽 §17.4). 이쪽은 결정이 자기가 밟고 선 상태를 들고 있고 적용 시점에 대조한다 — 같은 형태의 올바른 판본이다.
  • ADVANCED_STABLE 11 · EXPERIMENTAL 3 · WATCH 1 의 분포가 능력 성격과 맞는 것 — 제어 평면을 끌고 오는 XDS, 부하를 복제하는 HEDGING, 이름 해석을 갈아끼우는 CUSTOM_LOAD_BALANCER 만 이중 승인 대상이다.

Source anchors

src/grpc-advanced/grpc-advanced-bootstrap/build.gradle:1-12
main/java/…/bootstrap/GrpcAdvancedFeatureFlags.java:1-105
main/java/…/bootstrap/GrpcAdvancedModuleGuard.java:1-84
main/java/…/bootstrap/GrpcAdvancedCapability.java:1-65
main/java/…/bootstrap/GrpcCapabilityGrade.java:1-38
main/java/…/bootstrap/GrpcAdvancedCapabilityDisabledException.java:1-42
main/java/…/release/GrpcAdvancedPromotionGate.java:1-85
main/java/…/release/GrpcAdvancedPromotionEvidence.java:1-75
main/java/…/release/GrpcAdvancedSupportMatrix.java:1-66
main/java/…/release/GrpcAdvancedPromotionDecision.java:1-50
test/java/…/release/GrpcAdvancedPromotionGateTest.java:1-150
test/java/…/bootstrap/GrpcAdvancedModuleGuardTest.java:1-129
grpc-advanced/grpc-advanced-diagnostics/…/GrpcChannelDiagnosticsPolicy.java:46-53 (available 소비)
grpc-advanced/grpc-advanced-resilience/…/xds/GrpcXdsStartupGuard.java:33-38 (available 소비)

A20-GRPC-ADVANCED-COMPAT. grpc-advanced-compat

분석 중에는 grpc/GRPC-ADVANCED-COMPAT.md 파일이었다. 224줄.

/shared/codebase/clean-architecture-backend-template/src/grpc-advanced/grpc-advanced-compat/src/main/resources/envoy/envoy.yaml

grpc-advanced-compat 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 17파일 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc-advanced/grpc-advanced-compat SSOT owner: grpc-advanced-compat integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • runtime_memberships: [] — build-only
  • 다섯 다리: gRPC-Web · Servlet · Spring Integration · Reactor · Kotlin
패키지 파일 LOC
web 4 220
kotlin 3 164
servlet 3 152
reactor 4 233
integration 3 193
Coverage ledger
scope count disposition reason
main/java/** 17 FULL_READ 962줄 전 본문
main/resources/envoy/envoy.yaml 1 FULL_READ 70줄 전문. 참조 프록시 설정 — 이전 판의 ledger 에 아예 없었다
test/java/** 5 FULL_READ 523줄
build.gradle 1 FULL_READ 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체와 코틀린 레인의 처리

// build.gradle:6-10
// No Kotlin source set (adaptation D7): this repository has no Kotlin toolchain, so the Kotlin lane
// is expressed as a Java-side boundary contract whose compatibility gate fails closed until a real
// toolchain lane exists. Everything the gate would otherwise assert — one schema source, coroutine
// cancellation propagation, Flow backpressure inside the Stable buffer limits, evidence type
// preservation — is a checkable contract without it.

그리고 게이트가 그 판단을 코드로 반복한다.

"Fails closed in this repository, and says so rather than reporting a pass it cannot justify… a gate that reported success anyway would put an unverified claim in the support matrix."

blockers 는 다섯 항목을 낸다. 넷은 프로파일에서 확인 가능하고, 다섯째가 툴체인 레인 부재다. supportableHere() 는 상수 거짓이다.

이 처리가 이 저장소의 다른 곳(grpc-advanced-diagnostics 의 인프라 테스트킷 계약)과 같은 원칙이다 — 인프라 없이 도는 묶음은 통과하고 아무것도 세우지 않는다.

2. 다리마다 무엇을 거절하는가

다리 거절
gRPC-Web 브라우저에 노출된 메서드 중 gRPC-Web 이 나를 수 없는 RPC 종류
Servlet 컨테이너가 제공하지 않는 전송 설정 요구
Kotlin 다섯 블로커(툴체인 레인 포함)
Spring Integration 변환기 없는 다리, 허용 목록 밖 헤더
Reactor (§17.2)

gRPC-Web 의 근거:

"Two schemas — one for browsers, one for services — is how a field ends up meaning something different depending on which client asked, and the divergence is only visible to whoever reads both files."

Servlet 의 근거:

"Refuses rather than warns, because the setting would otherwise be accepted and ignored. A keepalive configured on a Servlet deployment does nothing, the connections behave as the container decides, and the investigation starts from the assumption that the setting is in force."

그리고 Servlet 실행이 Netty 인증을 대신할 수 없다는 것을 상수로 못박는다.

3. Spring Integration 다리가 무엇을 약속하지 않는가

"A Spring Integration Message accumulates headers as it moves through a flow — routing keys, correlation ids, errors channels, whatever a transformer added — and copying them onto gRPC metadata sends a service's internal plumbing across the network."

"The bridge does not add durability. Spring Integration channels can look like a broker, and a bridge that implied acknowledgement or redelivery semantics would be promising something gRPC does not do."

변환기가 없는 다리는 생성자가 거부한다 — 반사에 맡기는 것이 예상 밖 타입이 유선에 닿는 경로다.

12. negative-space probes

12.1 도달성. Advanced 가족이므로 배선 경로가 없다. 그 위에 이 리프에는 테스트조차 없는 타입이 둘 있다(§17.2).

12.2 대조군 — 메타데이터 경로 둘. grpc-clientGrpcClientMetadataPolicy.materialize 는 허용 목록으로 거른 뒤 budget.check(accepted) 를 부른다. 이 리프의 GrpcIntegrationBridgePolicy.metadataFrom 은 허용 목록으로 거르고 예산을 부르지 않는다(§17.1).

12.3 리프 전체의 외부 참조가 0 이다. 재통독에서 다섯 패키지를 각각 확인했다.

dev.caskeleton.grpc.advanced.{web, servlet, integration, reactor, kotlin}   → 리프 밖 참조 0

같은 Advanced 가족의 grpc-advanced-bootstrap 조차 이 리프의 타입을 하나도 부르지 않는다. Advanced 는 기능 플래그로 도달한다는 것이 이 가족의 규약인데, 그 플래그가 가리킬 대상이 배선되어 있지 않다.

12.4 드리프트. build.gradle 이 서술한 다섯 다리와 코틀린 레인의 처리 방식이 코드와 일치한다. 다만 ledger 가 main/resources 를 세지 않고 있었다(§17.3 의 재료가 거기 있다).

16. 확인하지 못한 것

  • 실제 브라우저·프록시·서블릿 컨테이너로 어떤 다리도 돌리지 않았다. 그 인프라가 필요하다는 것이 이 가족의 기록이다.
  • 코틀린 툴체인이 없으므로 코틀린 계약 넷을 실행으로 확인할 수 없다.

17. 손볼 것

17.1 P3 — 통합 다리의 메타데이터 조립이 메타데이터 예산을 검사하지 않는다
public Map<GrpcMetadataKey, String> metadataFrom(Map<String, Object> messageHeaders) {
  Map<GrpcMetadataKey, String> metadata = new LinkedHashMap<>();
  headerAllowlist.forEach(key -> {
    Object value = messageHeaders.get(key.name());
    if (value != null) { metadata.put(key, String.valueOf(value)); }
  });
  return Map.copyOf(metadata);
}

허용 목록으로 키를 거르지만 값의 크기도, 합계도 보지 않는다.

클래스 javadoc 자신이 예산을 이 정책의 이유 중 하나로 든다 — 흐름의 내부 배관을 네트워크로 보내면 "it counts against the metadata budget."

그리고 같은 저장소의 다른 메타데이터 경로는 예산을 검사한다.

// GrpcClientMetadataPolicy.materialize
proposed.forEach((key, value) -> { if (allowed.contains(key) && value != null) accepted.put(key, value); });
budget.check(accepted);      // ← 이 줄이 이 다리에는 없다

String.valueOf(value) 이므로 헤더 값이 임의의 객체일 때 그 문자열 표현이 그대로 실린다. Spring Integration 헤더에는 컬렉션이나 도메인 객체가 흔히 들어가므로 값 하나가 클 수 있다.

수정은 이 record 에 GrpcMetadataBudget 를 성분으로 추가하고 metadataFrom 끝에서 검사하는 것이다. 형태가 이미 옆 리프에 있다.

17.2 P3 — 반응형 표면 두 타입은 테스트조차 없다
ReactiveGrpcClient          저장소 전체에서 등장하는 파일 1개 (자기 자신)
ReactiveGrpcServerAdapter   저장소 전체에서 등장하는 파일 1개 (자기 자신)

이 가족의 다른 미참조 Advanced 타입은 전부 테스트가 하나씩 있다 — 같은 패키지의 GrpcReactorCancellationBridge 는 2개 파일, GrpcReactorContextBridge 는 4개 파일에 등장한다.

두 타입은 채택자가 부를 표면이므로 production 참조 0 이 설계와 모순되지는 않는다. 어긋나는 것은 검증이다. 채택자용 표면이면 그 계약이 무엇인지를 테스트가 붙들어야 하고, 이 가족은 다른 곳에서 정확히 그렇게 한다.

ReactiveGrpcClient 의 javadoc 이 "Exposes a unary call as a Mono and a server stream as a Flux" 라고 적는데, 그 사상이 취소와 배압에서 어떻게 동작하는지는 어디에서도 확인되지 않는다. 같은 리프의 GrpcReactorCancellationBridge 가 취소 전파를 다루므로 둘을 함께 검증할 자리가 이미 있다.

17.3 P3 — 저장소가 참조 프록시 설정을 갖고 있는데, 그것을 판정할 코드에 넣지 않는다

이 리프에는 두 가지가 함께 있다.

  • GrpcWebProxyContract.violations(profile, exposedHeaders, allowedOrigins) — 프록시 설정이 브라우저 클라이언트에게 통할지 판정하는 코드.
  • src/main/resources/envoy/envoy.yaml — 그 설정의 참조 구현.

그리고 설정 파일 자신이 그 관계를 주장한다.

# Shipped as a resource rather than as documentation prose because GrpcWebProxyContract asserts
# against it: the CORS allowlist, the exposed trailer headers and the TLS termination are the three
# things a browser client silently fails without, and a contract nobody checks is a contract that
# drifts from whatever is actually deployed.

GrpcWebProxyContract 는 이 파일에 대해 아무것도 단언하지 않는다. 판정기는 시험에서 리터럴 집합을 받고, 참조 설정은 시험에서 문자열 포함으로만 확인된다.

// GrpcWebCompatibilityGateTest
assertThat(GrpcWebProxyContract.violations(profile, requiredExposedHeaders(), Set.of("*")))   // ← 리터럴
    .anySatisfy(v -> assertThat(v).contains("defeats the profile's allowlist"));

String envoy = resource("envoy/envoy.yaml");
assertThat(envoy)
    .contains("expose_headers: \"grpc-status,grpc-message")                                   // ← 부분 문자열
    .contains("exact: \"https://app.example.com\"");

그래서 참조 설정이 grpc-status 를 노출하는지는 문자열이 확인하고, 그 노출이 충분한지requiredExposedHeaders() 가 정의하는데, 둘을 잇는 코드가 없다. 필수 트레일러 목록이 늘어나면 판정기는 새 항목을 요구하고 참조 설정은 옛 문자열로 계속 통과한다.

이 리프의 다른 판정기들과 다른 점은 재료가 이미 저장소에 있다는 것이다 — grpc-testkit §17.5·grpc-server §17.1 은 스캔할 대상 자체를 만들어야 하지만, 여기서는 파일 하나를 파싱하면 된다.

수정은 시험이 envoy.yamlexpose_headersallow_origin(exact:)을 뽑아 GrpcWebProxyContract.violations 에 넣고 비어 있음을 단언하는 것이다. 그러면 참조 설정과 계약이 한 곳에서 함께 움직인다.

확인된 설계(문제 아님)
  • 코틀린 게이트가 닫힌 실패를 하고 그 이유를 말하는 것 — 정당화할 수 없는 통과를 보고하지 않는다.
  • 코틀린 계약 넷을 툴체인 없이도 확인 가능하게 만든 것 — 나중에 필요한 것은 레인 추가이지 계약 작성이 아니다.
  • 브라우저와 기본 클라이언트가 한 스키마를 쓰게 한 것.
  • Servlet 이 제공하지 않는 설정을 경고가 아니라 거절로 다룬 것.
  • Servlet 실행이 Netty 인증을 대신하지 못한다고 못박은 것.
  • 통합 다리가 브로커 의미론을 약속하지 않는다고 상수로 밝힌 것.
  • 변환기 없는 다리를 생성자가 거부한 것.
  • 다리가 생성된 스텁·서비스 API 를 대체하지 않는다고 밝힌 것.

Source anchors

src/grpc-advanced/grpc-advanced-compat/build.gradle:1-14
main/java/…/kotlin/GrpcKotlinCompatibilityGate.java:1-69
main/java/…/web/GrpcWebCompatibilityGate.java:1-56
main/java/…/servlet/GrpcServletStartupValidator.java:1-51
main/java/…/integration/GrpcIntegrationBridgePolicy.java:1-78
main/java/…/reactor/(ReactiveGrpcClient · ReactiveGrpcServerAdapter · GrpcReactorCancellationBridge · GrpcReactorContextBridge)
main/java/…/web/(GrpcWebProfile · GrpcWebProxyContract · GrpcWebRpcSupport)
main/java/…/servlet/(GrpcServletCompatibilityProfile · GrpcServletCapabilityMatrix)
main/java/…/kotlin/(GrpcCoroutineContextBridge · GrpcKotlinProfile)
main/java/…/integration/(GrpcIntegrationInboundGateway · GrpcIntegrationOutboundGateway)
src/grpc/grpc-client/…/GrpcClientMetadataPolicy.java (예산 검사 대비)

A20-GRPC-ADVANCED-DIAGNOSTICS. grpc-advanced-diagnostics

분석 중에는 grpc/GRPC-ADVANCED-DIAGNOSTICS.md 파일이었다. 265줄.

grpc-advanced-diagnostics 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 4파일 277줄, test 1파일 229줄과 픽스처 1개 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc-advanced/grpc-advanced-diagnostics SSOT owner: grpc-advanced-diagnostics integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • allowed_dependencies: ["grpc-core-api", "grpc-client", "grpc-advanced-bootstrap"]
  • runtime_memberships: [] — build-only (Advanced 가족 전체가 그렇다)
파일 LOC
GrpcAdvancedInfrastructureTestkit 87
GrpcDiagnosticsRedactor 72
GrpcChannelDiagnosticsSnapshot 63
GrpcChannelDiagnosticsPolicy 55
main 합계 277
GrpcChannelDiagnosticsPolicyTest 229
xds/control-plane-snapshot.json 24
Coverage ledger
scope count disposition reason
main/java/** 4 FULL_READ 전 본문 축자 확인
test/java/** 1 FULL_READ 229줄 · 테스트 11개
test/resources/xds/*.json 1 FULL_READ 24줄 픽스처
build.gradle 1 FULL_READ 10줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

진단 표면(Channelz·CSDS)과 그것을 게시 가능하게 만드는 편집기, 그리고 고급 능력이 무엇을 상대로 검증되어야 하는지를 이름 짓는 테스트킷 계약을 담는다.

편집기 javadoc 이 왜 이것이 필요한지 적는다.

"Channelz is unusually dangerous to expose because it is genuinely useful: it holds every socket's local and remote address, the security details of each connection, and per-call state… once the endpoint exists the whole of it is one authorization mistake away from being readable."

2. 두 겹의 게이트

GrpcChannelDiagnosticsPolicy 는 네트워크와 역할 두 게이트를 모두 요구하고, 하나라도 비면 생성자가 거부한다.

"diagnostics need both a network and a role gate; Channelz holds every socket's peer and security detail, so either gate alone is the whole surface"

그리고 등록 판정이 능력 깃발에 걸려 있다. CSDS 는 Channelz 가 켜져 있고 xDS 도 켜져 있을 때만 등록된다.

"A CSDS service on a deployment that does not use xDS answers every query with nothing, which is harmless, and advertises a control-plane surface that does not exist, which is not."

3. 스냅숏이 스스로를 검사한다

GrpcChannelDiagnosticsSnapshot 정규 생성자가 두 가지를 거부한다.

maskedSocketAddresses.stream()
    .filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address)))
      // 마스킹되지 않은 주소
xdsResourceVersions.keySet().stream()
    .filter(GrpcDiagnosticsRedactor::forbiddenField)
      // 금지된 필드 이름

즉 편집을 거치지 않은 값으로는 스냅숏을 만들 수 없다. §17.1 이 그 검사의 범위를 다룬다.

4. 마스킹의 형태

주소는 버리지 않고 가린다.

"An operator has to be able to tell two subchannels apart, and a stable mask does that without publishing where they point… The last two octets go; the first two stay, because 'which subnet' is a real diagnostic question and 'which host' is not one the diagnostics endpoint should answer."

5. 인프라 없는 증거를 거부하는 계약

GrpcAdvancedInfrastructureTestkit 이 능력별로 필요한 실제 인프라를 이름 짓는다.

능력 필요 인프라
GRPC_WEB gRPC-Web 프록시
SERVLET_COMPAT 서블릿 컨테이너
XDS 멈출 수 있는 xDS 통제 평면
KOTLIN 코틀린 툴체인
나머지 11종 없음

근거가 javadoc 에 있다.

"gRPC-Web without a proxy tests a code path no browser will take; a Servlet profile without a container tests the profile object; xDS without a control plane cannot exercise the case that matters, which is the control plane going away. In all three, a suite that runs without the infrastructure passes and establishes nothing, which is worse than not having one."

10. 테스트 레인

11개 테스트 229줄. 두 게이트, CSDS 조건부 등록, 금지 필드 제거, 마스킹, 스냅숏 거부와 수용, 커밋된 xDS 픽스처의 편집, 능력별 인프라 목록을 확인한다.

12. negative-space probes

12.1 도달성. Advanced 가족이므로 배선 경로가 없다. 리프 밖 참조도 없고, 이 리프를 의존 선언한 모듈도 없다.

$ grep -rn "advanced.diagnostics" --include=*.java src/ | grep -v grpc-advanced-diagnostics/
grpc-core-api/…/GrpcStableModuleCatalog.java:42:   "grpc-advanced-diagnostics");   ← 목록 안의 문자열
$ grep -rn "grpc-advanced-diagnostics" --include=*.gradle src/
(매치 없음)

방향을 뒤집으면 이 리프는 grpc-advanced-bootstrap 의 실제 소비자 둘 중 하나다 — GrpcChannelDiagnosticsPolicyGrpcAdvancedModuleGuard.available 을 두 번 부른다(그쪽 §12.1). 이 가족에서 리프끼리 실제로 코드가 닿는 몇 안 되는 자리다.

12.2 선언된 의존 셋 중 둘이 쓰이지 않는다.

api project(':grpc:grpc-core-api')          // import 0
api project(':grpc:grpc-client')            // import 0
api project(':grpc-advanced:grpc-advanced-bootstrap')   // import 4줄

리프의 자바 4파일이 갖는 dev.caskeleton import 는 넷뿐이고 전부 bootstrap 것이다.

GrpcAdvancedInfrastructureTestkit.java:3   GrpcAdvancedCapability
GrpcChannelDiagnosticsPolicy.java:3,4,5    GrpcAdvancedCapability · GrpcAdvancedFeatureFlags · GrpcAdvancedModuleGuard

grpc-client 는 특히 눈에 띈다 — Channelz 진단이 채널을 다루는 주제이므로 의존 선언은 자연스럽게 읽히는데, 이 리프의 스냅숏은 채널 타입을 쓰지 않고 String channelProfileString connectivityState 로 받는다. 진단 값 객체가 채널 타입에서 독립적인 것 자체는 설계로 읽히고, 그렇다면 남은 것은 쓰이지 않는 의존 선언이다.

같은 형태를 세 리프에서 기록했다 — grpc-advanced-edition §12.2(셋 다 미사용), grpc-spring-boot-starter §12.3(셋 미사용), grpc-observability §12.1(두 모듈이 이 리프를 api 로 노출하면서 쓰지 않음).

12.2 대조군. 이 저장소의 다른 편집기와 비교하면 방향이 같다 — grpc-observability 의 태그 정책은 허용 목록으로, 이쪽은 금지 패턴 + 마스킹으로 같은 문제(내용이 관측 표면으로 새는 것)를 푼다.

12.3 대조군 — 같은 두 리터럴이 두 리프에 있다.

// grpc-advanced-diagnostics: GrpcChannelDiagnosticsPolicy.standard()
new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN"));

// grpc-spring-boot-starter: GrpcPlatformAutoConfiguration.grpcReflectionPolicy(...)
new GrpcReflectionPolicy(properties.getReflectionMode(),
    java.util.Set.of("admin"), java.util.Set.of("ROLE_PLATFORM_ADMIN"));

관리 네트워크 이름과 관리 역할 이름이 같은 값으로 두 곳에 손으로 적혀 있고, 둘을 묶는 상수가 없다. 하나를 바꾸면 다른 하나가 남는다. 스타터 쪽은 그 리터럴이 설정 표면에 노출되지 않는다는 별도 문제도 있다(그쪽 §17.4).

12.4 드리프트. build.gradle 이 서술한 세 요소(Channelz/CSDS 진단, 편집기, 인프라 테스트킷 계약)가 전부 존재한다. 드리프트 없음.

16. 확인하지 못한 것

  • 실제 Channelz 서비스를 띄워 스냅숏을 만들지 않았다. 배선 경로가 없다.
  • IPv6 주소로 스냅숏을 만들어 §17.1 을 실행으로 재현하지 않았다. 정규식과 생성자 검사로 판정했다.
  • 테스트를 실행하지 않았다. 11개 전부 본문으로만 확인했다.
  • 두 의존이 쓰이지 않는다는 것(§12.2)은 ^import dev.caskeleton grep 으로 판정했다.

17. 손볼 것

17.1 P2 — 마스킹이 IPv4 만 알고, 그 결과 "마스킹되지 않은 주소" 검사가 나머지 형태를 전부 통과시킨다
private static final Pattern IPV4_WITH_PORT =
    Pattern.compile("\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(:\\d{1,5})?\\b");

public static String maskAddress(String address) {
  if (address == null || address.isBlank()) { return "unknown"; }
  return IPV4_WITH_PORT.matcher(address)
      .replaceAll(m -> m.group(1) + "." + m.group(2) + ".x.x");
}

IPv4 가 아닌 주소는 패턴에 맞지 않아 입력 그대로 반환된다.

그리고 스냅숏 생성자의 검사는 이렇게 되어 있다.

.filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address)))

마스킹 결과가 입력과 같으면 이미 마스킹된 것으로 판정한다. 그러므로 IPv4 가 아닌 주소는 전부 이 검사를 통과한다.

입력 maskAddress 결과 생성자 판정
10.4.13.201:9090 10.4.x.x 거부(마스킹 필요)
10.4.x.x 10.4.x.x 수용
[2001:db8::4:13:201]:9090 입력 그대로 수용
pod-3.svc.cluster.local:8080 입력 그대로 수용
unix:/var/run/grpc.sock 입력 그대로 수용

세 번째와 네 번째가 문제다. 이 플랫폼이 겨냥하는 배포 형태가 쿠버네티스이고(grpc-discovery 전체가 그 주제다), 헤드리스 레코드의 엔드포인트는 파드 DNS 이름이며 이중 스택 클러스터에서는 IPv6 주소다. 편집기가 막으려 한 것이 정확히 그것이다 — "a diagnostics endpoint that publishes peer addresses publishes every tenant's connection."

unix 소켓 경로도 통과한다. 그것은 호스트 파일 시스템 경로다.

테스트가 이것을 볼 수 없다. 테스트의 주소 리터럴이 전부 IPv4 다 — 10.4.13.201:9090 · 10.9.13.201 · 10.4.x.x · 10.5.x.x. IPv6 도 호스트 이름도 없다.

수정. 마스킹을 형태별로 나눈다. IPv6 는 앞 두 그룹만 남기고 나머지를 :x:x 로, 호스트 이름은 최상위 라벨 몇 개만 남기고, 그 밖의 형태는 unknown 으로 접는다. 그리고 검사를 "결과가 입력과 같으면 통과" 가 아니라 "알려진 마스킹 형태와 일치해야 통과" 로 뒤집는다. 지금 형태는 마스킹이 모르는 입력을 전부 안전하다고 판정한다.

17.2 P3 — 금지 필드 검사가 키에만 적용되고 값에는 적용되지 않는다
xdsResourceVersions.keySet().stream().filter(GrpcDiagnosticsRedactor::forbiddenField)

redact(...) 도 같다 — 금지 이름의 키를 버리고, 남은 값은 주소 필드일 때만 마스킹한다. 값 자체가 자격증명 형태인지는 보지 않는다.

grpc-observability 의 태그 정책은 값도 본다(UUID·sha256:·bearer 패턴). 같은 저장소의 두 관측 편집기가 값 검사에서 갈린다.

xDS 자원 버전은 보통 짧은 숫자나 해시라 도달성이 낮다. 기록하는 이유는 두 편집기의 규율이 다르다는 점이다.

17.3 P3 — "실환경 증거" 가 두 리프에 반씩 있고 서로 만나지 않는다

이 리프가 능력별로 무엇이 실환경인지 정의한다.

public static Set<Infrastructure> requiredFor(GrpcAdvancedCapability capability) {  }
public static List<String> missingInfrastructure(GrpcAdvancedCapability capability, Set<Infrastructure> available) {  }

그리고 grpc-advanced-bootstrap 이 승격 증거로 그것을 요구한다.

public record GrpcAdvancedPromotionEvidence(
    GrpcAdvancedCapability capability, , boolean realEnvironmentTest) {  }
//                                        ^^^^^^^^^^^^^^^^^^^^^^^^^^ 불리언 하나

GrpcAdvancedPromotionGate.evaluate 는 그 불리언이 거짓이면 "xds has no real environment test" 를 차단 사유로 낸다. 그 불리언을 무엇으로 채워야 하는지는 그쪽에서 답하지 않고, 답하는 코드가 이 리프에 있는데 두 쪽이 서로를 부르지 않는다.

결과: GrpcAdvancedPromotionEvidence.complete(XDS, 7일)realEnvironmentTest = true 를 그냥 넣는다. xDS 통제 평면이 실제로 있었는지와 무관하다. 이 리프의 javadoc 이 경계한 상태 — "a suite that runs without the infrastructure passes and establishes nothing" — 를 승격 게이트가 그대로 통과시킬 수 있다.

왜 P3 인가. 두 리프 모두 배선되지 않았고 승격은 사람이 수행한다. 다만 이 두 조각이 존재하는 이유가 "그 판단을 코드로 적어 두는 것" 이므로, 판단의 절반이 다른 절반을 부르지 않는 것은 그 목적에 어긋난다. grpc-advanced-edition §17.2 가 같은 가족에서 같은 모양을 기록했다 — 두 승격 게이트가 서로를 부르지 않는다.

수정. GrpcAdvancedPromotionEvidence.realEnvironmentTest 를 불리언 대신 Set<Infrastructure> availableInfrastructure 로 바꾸고, 게이트가 missingInfrastructure(capability, available) 를 불러 그 결과를 차단 사유에 합친다. 그러면 "실환경 테스트를 했다" 가 선언이 아니라 능력별 목록에 대한 대조가 된다. 의존 방향도 맞는다 — 이 리프가 이미 bootstrap 을 의존하므로, 게이트가 이쪽을 부르려면 방향을 뒤집거나 Infrastructure 열거형을 bootstrap 으로 옮겨야 한다는 점은 함께 정해야 한다.

확인된 설계(문제 아님)
  • 두 게이트를 모두 요구하고 하나만 있으면 생성자가 거부하는 것.
  • CSDS 를 xDS 사용 시에만 등록하는 것과 그 근거 — 존재하지 않는 통제 평면 표면을 광고하지 않는다.
  • 주소를 버리지 않고 가리는 판단 — 두 서브채널을 구별할 수 있어야 한다.
  • 스냅숏이 스스로 편집 여부를 검사하는 것 — 편집을 우회한 값으로는 만들 수 없다(형태 범위는 §17.1).
  • 능력별로 필요한 실제 인프라를 이름 지은 것 — 인프라 없이 통과하는 묶음은 없는 것보다 나쁘다. (승격 게이트와의 연결 없음은 §17.3.)
  • requiredFor 의 switch 가 15개 능력을 전부 나열하고 default 를 두지 않은 것 — 능력이 하나 늘면 이 파일이 컴파일되지 않는다. 새 능력이 조용히 "인프라 불필요" 로 분류되지 않는다.
  • 픽스처가 금지 대상 셋을 일부러 담고 있는 것 — 통제 평면 토큰·피어 인증서·원시 소켓 주소. 파일 안 주석이 그 의도를 적고("so the redactor is tested against data shaped like the real thing rather than against a string somebody invented for the assertion"), 테스트가 편집 전에 그 셋이 실제로 들어 있는지부터 단언한 뒤 편집 결과를 본다.

Source anchors

src/grpc-advanced/grpc-advanced-diagnostics/build.gradle:1-10
main/java/…/diagnostics/GrpcAdvancedInfrastructureTestkit.java:1-87
main/java/…/diagnostics/GrpcDiagnosticsRedactor.java:1-72
main/java/…/diagnostics/GrpcChannelDiagnosticsSnapshot.java:1-63
main/java/…/diagnostics/GrpcChannelDiagnosticsPolicy.java:1-55
test/java/…/diagnostics/GrpcChannelDiagnosticsPolicyTest.java:1-229
test/resources/xds/control-plane-snapshot.json:1-24

A20-GRPC-ADVANCED-EDITION. grpc-advanced-edition

분석 중에는 grpc/GRPC-ADVANCED-EDITION.md 파일이었다. 271줄.

grpc-advanced-edition 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 6파일 326줄, 스키마 리소스 1개 28줄, test 2파일 211줄 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc-advanced/grpc-advanced-edition SSOT owner: grpc-advanced-edition integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • allowed_dependencies: ["grpc-core-api", "grpc-proto-contract", "grpc-advanced-bootstrap"]
  • runtime_memberships: [] — build-only
파일 LOC
GrpcEditionCompatibilityReport 72
GrpcEdition2026WatchReport 70
GrpcEdition2024Gate 61
GrpcEdition2026Guard 48
GrpcEdition2024Policy 47
GrpcEdition2026Status 28
main java 합계 (6파일) 326
compatibility.proto 28
GrpcEdition2024GateTest · GrpcEdition2026GuardTest 118 · 93
Coverage ledger
scope count disposition reason
main/java/** 6 FULL_READ 전 본문 축자 확인
main/resources/proto/edition2024/*.proto 1 FULL_READ 28줄 전문
test/java/** 2 FULL_READ 211줄 전 본문 · 테스트 13개
build.gradle 1 FULL_READ 10줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-5
// Protobuf Edition lanes. Edition 2024 is an opt-in Advanced lane that must produce cross-consumer
// compile evidence before anything public moves onto it; Edition 2026 is a watch lane that records
// release/toolchain status and is refused as a Stable contract source.

두 레인이 성격이 다르다. 하나는 증거를 만들어야 승격되는 레인이고, 하나는 사실만 기록하는 감시 레인이다.

2. Edition 2024 — 두 결정을 분리한다

GrpcEdition2024Policy 는 모듈 옵트인과 공개 서비스 이동을 따로 다룬다.

public boolean serviceMayMove(String serviceName) {
  return !publicServices.contains(serviceName) || promotionApproved;
}

"the opt-in is a build decision and the promotion is a consumer-migration decision."

그 이유가 클래스 javadoc 에 있다.

"an edition change is invisible to the schema's owner and consequential for its consumers: the wire bytes are usually identical, so nothing fails locally, and the breakage appears in whichever consumer's generator handles the edition's features differently."

3. 세 종류의 호환성

GrpcEditionCompatibilityReport 는 하나가 아니라 셋을 본다.

비교 답하는 질문
wire 저장된 메시지와 이동 중 메시지가 계속 디코딩되는가
JSON 전사 프록시와 브라우저 클라이언트가 계속 동작하는가
source(툴체인별) 생성된 코드가 여전히 컴파일되는가

"An edition migration can preserve the first two and break the third for a language whose generator handles the edition's features differently — which is exactly the failure this lane exists to find before a public service moves."

그리고 툴체인 결과가 비어 있으면 생성자가 거부한다 — "Java alone is not cross-language evidence."

4. 레인 실패의 범위

blocksStableRelease()    항상 false
blocksEditionPromotion()  항상 true

"Without that split, an opt-in lane that nobody depends on can hold up every release, and the first response to that is to stop running the lane."

두 메서드 모두 상수를 돌려주고 javadoc 이 그 이유를 적는다 — "Stated as a method so the property is tested rather than described."

5. Edition 2026 — 감시 레인

네 게이트를 따로 추적한다 — 명세, protoc, Buf, 자바 런타임.

"An edition can be released by the specification while protoc does not emit it, or emitted while Buf cannot lint it, or lintable while the Java runtime does not implement its features. A single 'supported yes/no' flag collapses four different waiting states into one."

보고서는 날짜를 필수로 요구한다 — 날짜 없는 감시 기록은 오래된 메모와 구분되지 않는다.

그리고 가드가 보고서와 무관하게 거부한다.

"The guard is deliberately not conditional on the watch report… letting the same record also authorise use means the moment somebody marks four fields SUPPORTED, a schema can move onto an edition with no promotion decision, no consumer migration and no ADR. Turning the watch into a lane that can be used is a code change here, and that is the point."

10. 테스트 레인

두 테스트 211줄 · 13개.

GrpcEdition2024GateTest 7개 — 모듈 옵트인의 기본 꺼짐, 공개 서비스의 승격 요구, 자바 단독이 증거가 아님(빈 툴체인 맵 거부 포함), 세 호환성의 분리, 승격 차단 셋, 레인 실패의 격리, 그리고 픽스처 파일 자체를 리소스로 읽어 edition = "2024"; 로 시작하는지와 features.field_presence = EXPLICIT 를 담는지 대조하는 것.

GrpcEdition2026GuardTest 6개 — 네 게이트의 개별 추적, 날짜 필수, SUPPORTED 만 usable, 전부 SUPPORTED 여도 가드가 거부, 거부 메시지의 미해결 항목, 감시 레인이 Stable 빌드를 막지 않음.

theGuardIsNotConditionalOnTheReport 가 이 레인에서 가장 중요한 한 줄을 붙든다 — 보고서가 readyToEvaluate() == true 인 상태를 만들어 놓고, 그래도 requireNotUsedAsSource 가 던지는지 확인한다. 기록이 사용을 허가하지 않는다는 설계가 테스트로 고정되어 있다.

12. negative-space probes

12.1 도달성. 리프 밖에서 이 리프를 참조하는 것이 하나도 없다 — 자바 코드도, build.gradle 도.

$ grep -rn "advanced.edition" --include=*.java src/ | grep -v grpc-advanced-edition/
grpc-core-api/…/GrpcStableModuleCatalog.java:38:   "grpc-advanced-edition",   ← 목록 안의 문자열
$ grep -rn "grpc-advanced-edition" --include=*.gradle src/
(매치 없음)

12.2 선언된 의존 셋이 전부 쓰이지 않는다.

api project(':grpc:grpc-core-api')
api project(':grpc:grpc-proto-contract')
api project(':grpc-advanced:grpc-advanced-bootstrap')

이 리프의 자바 6파일에는 dev.caskeleton 으로 시작하는 import 가 한 줄도 없다.

$ grep -rn "^import dev.caskeleton" grpc-advanced/grpc-advanced-edition/src/main/java/
(매치 없음)

여섯 파일이 쓰는 것은 java.time · java.util 뿐이다. 세 의존 중 어느 것도 코드에 닿지 않는다.

셋 중 둘은 의도를 읽을 수 있다 — grpc-proto-contractcompatibility.proto 가 그쪽 스키마 규칙의 관할이라는 선언으로, grpc-advanced-bootstrapGrpcAdvancedCapability.EDITION_2024 가 이 레인의 등급을 들고 있다는 선언으로. 다만 어느 쪽도 코드로 연결되어 있지 않고, 그 연결 없음이 §17.2 가 지적한 "두 게이트가 서로를 부르지 않는다" 와 같은 사실의 빌드 파일 쪽 표현이다.

grpc-advanced-bootstrap §12.1 이 반대편에서 같은 것을 기록했다 — 그 리프를 의존 선언한 다섯 모듈 중 실제로 부르는 것은 둘뿐이고, 이 리프는 부르지 않는 셋 중 하나다.

12.3 대조군. 무조건 상수를 돌려주고 그것을 테스트가 붙드는 형태가 같은 가족의 GrpcAdvancedPromotionGate.capabilitiesDraggedAlong 과 같다. 이 리프에는 그런 메서드가 넷 있다 — blocksStableRelease · blocksEditionPromotion · allowedAsStableSource · blocksStableBuild.

그중 셋(blocksStableRelease · blocksEditionPromotion · blocksStableBuild)은 capabilitiesDraggedAlong 과 같은 한계를 갖는다 — 리터럴을 리터럴과 비교하므로, 그 속성이 실제로 지켜지는지는 이 저장소에 릴리스 파이프라인이 생겨야 알 수 있다. grpc-advanced-bootstrap §17.5 에 그 판정을 적어 두었다.

넷째 allowedAsStableSource 는 다르다. 같은 클래스의 requireNotUsedAsSource 가 그 상수와 독립적으로 무조건 던지고, theGuardIsNotConditionalOnTheReport 가 "전부 SUPPORTED 인 보고서"라는 실제 상태를 만들어 그 독립성을 확인한다. 상수 하나를 읽는 것이 아니라 설계 속성을 실행으로 밟는다.

12.2 대조군. 무조건 상수를 돌려주고 그것을 테스트가 붙드는 형태가 같은 가족의 GrpcAdvancedPromotionGate.capabilitiesDraggedAlong 과 같다. 이 저장소가 "주석이 주장하는 대신 테스트가 붙든다" 를 반복해서 쓴다.

12.5 저장소의 .proto 넷. 이 리프의 compatibility.protoedition = "2024"; 로 시작하므로 grpc-proto-contractPROTO3_SYNTAX 규칙에 걸린다. 그 검증기의 커밋 스키마 테스트가 파일 목록을 하드코딩해 이 파일을 판정하지 않으므로 지금은 충돌하지 않는다. 그 테스트를 전수 훑기로 바꾼다면(그쪽 §17.3) 이 파일에 대한 면제가 함께 필요하다.

12.4 드리프트. build.gradle 이 서술한 두 레인의 성격이 코드와 일치한다. 드리프트 없음.

16. 확인하지 못한 것

  • protoc 을 돌려 이 편집 파일이 실제로 컴파일되는지 확인하지 않았다. 저장소에 protobuf 플러그인이 없다.
  • 테스트를 실행하지 않았다. 13개 전부 본문으로만 확인했다.
  • 세 의존이 쓰이지 않는다는 것(§12.2)은 ^import dev.caskeleton grep 으로 판정했다. 같은 패키지 안의 타입이나 완전 한정명 사용이라면 잡히지 않는다 — 다만 이 리프의 패키지는 dev.caskeleton.grpc.advanced.edition 하나이고 세 의존의 패키지와 겹치지 않는다.
  • 편집 기능(features.field_presence = EXPLICIT)이 proto3 의 optional 과 같은 유선 결과를 내는지 확인하지 않았다. 그것이 이 레인의 질문이고 §17.1 이 그 질문에 답할 수 없는 이유다.

17. 손볼 것

17.1 P2 — 비교 픽스처에 비교 대상이 없다

compatibility.proto 의 주석이 존재 이유를 적는다.

"It exists to be compiled beside its proto3 twin and compared: same fields, same numbers, same JSON names, with presence expressed by the edition's features rather than by optional. The lane's question is whether the two produce the same wire bytes and the same JSON, and answering it needs both files to exist."

그 쌍둥이가 저장소에 없다.

$ grep -rn "DocumentSummary" --include=*.proto --include=*.java .
./src/grpc-advanced/grpc-advanced-edition/src/main/resources/proto/edition2024/compatibility.proto:17

한 곳뿐이다. 같은 필드와 번호를 proto3 로 선언한 파일이 없으므로 비교가 성립하지 않는다.

그리고 두 번째 전제도 없다. 이 저장소에는 protobuf 플러그인이 어디에도 없다 — grpc-proto-contractadapter-inbound-grpc 의 build.gradle 이 그 사실을 주석으로 명시한다. 그러므로 편집 파일도 proto3 파일도 컴파일되지 않고, 유선 바이트와 JSON 을 비교할 산출물 자체가 만들어지지 않는다.

결과적으로 GrpcEditionCompatibilityReport 는 사람이 손으로 채우는 기록이 된다. 승격 게이트가 그것을 읽어 판정하므로, 게이트의 입력이 측정이 아니라 선언이다.

등급. Advanced 가족이라 오늘의 배포에는 영향이 없다. 기록하는 이유는 이 리프의 목적이 "공개 서비스가 옮겨 가기 전에 그 실패를 찾는 것" 이고, 그 실패를 찾을 장치가 픽스처 하나만 있고 짝이 없다는 점이다.

수정. compatibility_proto3.proto 를 같은 디렉터리에 두어 필드·번호·JSON 이름을 맞추고, 두 파일을 컴파일해 산출물을 비교하는 레인을 만든다. 그 레인이 생기기 전까지는 GrpcEditionCompatibilityReport 가 측정이 아니라 선언이라는 것을 자바독에 적는 편이 낫다.

17.2 P3 — 승격 차단 목록에 담금 기간과 실환경 항목이 없다

GrpcEdition2024Gate.promotionBlockers 가 보는 것은 셋이다 — 호환성 보고서의 문제들, 소비자 이관 계획, 승격 ADR.

같은 가족의 GrpcAdvancedPromotionGateEDITION_2024 능력에 대해 일곱 증거 항목과 7일 담금을 요구한다. 두 게이트가 같은 능력의 승격을 서로 다른 기준으로 판정한다.

두 게이트가 각각 다른 것을 묻는다고 볼 수도 있다 — 하나는 편집 자체의 호환성, 하나는 능력의 운영 준비도. 다만 어느 쪽도 상대를 부르지 않고, 문서에도 두 게이트의 관계가 적혀 있지 않다. 승격을 실제로 수행할 때 어느 쪽을 만족해야 하는지가 코드에서 답해지지 않는다.

수정은 promotionBlockersGrpcAdvancedPromotionGate.evaluate 의 결과를 포함하게 하거나, 두 게이트의 역할 분담을 자바독에 적는 것이다.

17.3 P3 — 정책의 자바독이 하지 않는 거부를 한다고 적고, 승격 승인이 두 곳에 따로 있다

첫째, 서술과 코드가 어긋난다.

/** Copies both sets and refuses an approval nobody recorded. */
public GrpcEdition2024Policy {
  if (optedInModules == null || publicServices == null) {
    throw new IllegalArgumentException("an edition policy states both sets");
  }
  optedInModules = Set.copyOf(optedInModules);
  publicServices = Set.copyOf(publicServices);
}

"refuses an approval nobody recorded" 에 해당하는 검사가 없다. promotionApproved 는 읽히지도 검증되지도 않고 그대로 저장된다. new GrpcEdition2024Policy(Set.of(), Set.of(), true) — 옵트인한 모듈도 공개 서비스도 없는데 승인만 참인 정책 — 이 아무 저항 없이 만들어지고, serviceMayMove 는 모든 서비스에 참을 답한다.

둘째, 같은 사실이 두 곳에 따로 있다.

어디 무엇
GrpcEdition2024Policy.promotionApproved 승격이 승인되었는가 (record 성분)
GrpcEdition2024Gate.promotionBlockers(..., boolean promotionAdr) 승격 ADR 이 있는가 (메서드 인자)

게이트는 정책을 인자로 받지도, 참조하지도 않는다. 그래서 "ADR 이 없다"고 판정한 게이트와 "승인되었다"고 답하는 정책이 동시에 성립할 수 있고, 둘을 맞추는 코드가 없다. §17.2 가 지적한 "두 게이트가 서로를 부르지 않는다" 와 같은 구조가 정책과 게이트 사이에도 있다.

왜 P3 인가. 정책도 게이트도 production 호출자가 없고(§12.1), 승격은 사람이 수행하는 절차다. 다만 이 리프가 존재하는 이유가 "그 절차를 코드로 적어 두는 것" 이므로, 적힌 절차 안에서 같은 사실이 둘로 갈라져 있는 것은 그 목적에 어긋난다.

수정. promotionBlockersGrpcEdition2024Policy 를 받아 promotionApprovedpromotionAdr 자리에 쓰고, 정책 생성자가 자바독대로 "승인이 참이면 그 근거(공개 서비스 집합이 비어 있지 않을 것 등)"를 요구한다. 어느 쪽도 하지 않겠다면 자바독의 그 문장을 지운다.

확인된 설계(문제 아님)
  • 모듈 옵트인과 공개 서비스 이동을 분리한 것 — 빌드 결정과 소비자 이관 결정은 다른 결정이다.
  • 호환성을 셋으로 나눈 것 — 앞의 둘이 보존돼도 셋째가 깨지는 것이 이 레인이 찾는 실패다.
  • 툴체인 결과가 비면 생성자가 거부하는 것 — 자바 하나는 교차 언어 증거가 아니다.
  • 레인 실패가 Stable 릴리스를 막지 않게 한 것과 그 근거 — 막으면 사람들이 레인을 끄게 된다.
  • 감시 레인의 네 게이트를 따로 추적한 것.
  • 감시 보고서에 날짜를 필수로 둔 것.
  • 가드를 보고서와 무관하게 만든 것 — 기록이 사용을 허가하지 않는다. 사용하려면 코드를 고쳐야 한다.

Source anchors

src/grpc-advanced/grpc-advanced-edition/build.gradle:1-10
main/java/…/edition/GrpcEditionCompatibilityReport.java:1-72
main/java/…/edition/GrpcEdition2026WatchReport.java:1-70
main/java/…/edition/GrpcEdition2024Gate.java:1-61
main/java/…/edition/GrpcEdition2026Guard.java:1-48
main/java/…/edition/GrpcEdition2024Policy.java:1-47
main/java/…/edition/GrpcEdition2026Status.java:1-28
main/resources/proto/edition2024/compatibility.proto:1-28
test/java/…/edition/GrpcEdition2024GateTest.java:1-118
test/java/…/edition/GrpcEdition2026GuardTest.java:1-93

A20-GRPC-ADVANCED-RESILIENCE. grpc-advanced-resilience

분석 중에는 grpc/GRPC-ADVANCED-RESILIENCE.md 파일이었다. 237줄.

test/resources/xds/bootstrap.json

grpc-advanced-resilience 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) — src/main production 16파일 940줄 + src/test 4파일 577줄 + src/test/resources 1파일 축자 통독 완료. STRUCTURAL_ONLYgradle.lockfile 하나. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc-advanced/grpc-advanced-resilience SSOT owner: grpc-advanced-resilience integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • allowed_dependencies: core-api · policy · client · discovery · advanced-bootstrap
  • runtime_memberships: [] — build-only
패키지 파일 LOC
resilience (헤징) 4 242
xds 4 252
discovery (사용자 정의 리졸버·LB) 7 446
Coverage ledger
scope count disposition reason
main/java/** 16 FULL_READ 940줄 전 본문
test/java/** 4 FULL_READ 577줄
test/resources/xds/bootstrap.json 1 FULL_READ 커밋된 부트스트랩 픽스처 — §12.5
build.gradle 1 FULL_READ 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-4
// Resilience and discovery capabilities that Stable refuses: read-only unary hedging, the custom
// name resolver SPI, the custom load balancer SPI, and the proxyless xDS experimental profile.

2. 헤징은 읽기 전용 단항만

GrpcHedgingEligibility 가 세 조건을 순서대로 본다 — 단항이 아님, 읽기 전용이 아님, 재시도 소유자가 in-process 헤징을 허락하지 않음.

"A hedged mutation runs twice by design rather than by accident — both attempts are in flight, both may reach the server, and an idempotency key does not help because the second attempt is not a retry of a failure but a duplicate of a success in progress. A hedged stream is worse still: two streams deliver two prefixes."

멱등 키가 왜 도움이 되지 않는지를 한 문장으로 정리한 것이 이 리프의 핵심 판단이다.

3. 헤징 예산

토큰 버킷이다. 헤지 하나가 round(1/ratio) 토큰을 쓰고, 완료된 호출 하나가 토큰 하나를 돌려준다. 상한이 조용한 구간 뒤의 폭주를 제한한다.

비율 상한이 0.5 이고 그 근거가 적혀 있다.

"a hedging ratio above 0.5 means more than half of all calls are duplicated, which is a load decision rather than a latency one"

그리고 왜 재시도 예산보다 더 급한지도 적는다.

"A retry happens after a failure; a hedge happens on a call that might have succeeded, so a fleet that hedges without a budget doubles its backend load in the steady state and doubles it again the moment latency rises."

소비는 정확한 비교 후 교체 루프다 — 이 가족에서 원자성을 제대로 다룬 몇 안 되는 곳이다.

4. xDS 시작 가드

두 거절이 있고 javadoc 이 둘째를 더 중요하다고 적는다.

"xDS working in a deployment is not the same claim as the platform supporting it: it brings a control plane, its outage modes, its own security boundary and its own version skew, and the Stable support statement covers DNS and static targets. A support matrix that quietly widens is a support matrix nobody can rely on."

시작 차단 사유는 둘 — 능력이 사용 가능하지 않음, 그리고 애플리케이션이 재시도 정책을 함께 정의함.

"with xDS the control plane owns it, and defining it in both places makes the winner depend on resolution order"

부트스트랩 대조는 세 가지를 본다 — xds_servers 선언, 통제 평면 채널의 TLS, 프로파일의 자원 이름공간.

"a client whose bootstrap names a namespace the deployment did not configure subscribes successfully and receives another team's routing. Nothing errors — the control plane answers, the resources parse, and traffic goes somewhere nobody chose."

5. 사용자 정의 리졸버·LB 안전 규칙

리졸버는 주소와 검증된 서비스 설정만 줄 수 있다.

"A resolver runs inside the channel and speaks to something outside the deployment. Everything it can put into an update is therefore attacker-influenced in the worst case."

권한 문자열 형태 검사, 개정 번호의 전진 요구, 자격증명 형태 필드 거부 셋이다.

선택기는 두 규칙을 받는다 — 리졸버가 준 엔드포인트만 고를 수 있고, 던지면 결정적 대체로 떨어진다.

"a picker that can invent an address can send a request anywhere … a picker bug should degrade the balancing rather than the availability"

12. negative-space probes

12.1 도달성. Advanced 가족이므로 배선 경로가 없다.

12.2 대조군 — 원자성. GrpcHedgingBudget.tryConsume 이 비교 후 교체 루프를 정확히 쓴다. 같은 가족의 GrpcAdmissionController.tryAdmit·GrpcStreamAdmission.tryAdmit 은 같은 문제를 비원자적으로 푼다. 정본이 이 리프에 있다.

12.3 리프 밖 참조 0. 세 패키지 각각을 확인했다 — advanced.discovery·advanced.resilience·advanced.xds 를 import 하는 파일이 이 리프 밖에 없다. grpc-advanced-bootstrap 도 포함해서다.

12.4 드리프트. build.gradle 이 서술한 네 능력이 전부 존재한다.

12.5 대조군 — 커밋된 픽스처를 판정기에 넣는가. 이 리프는 넣는다.

// GrpcXdsStartupGuardTest: "the committed bootstrap fixture agrees with the profile it is meant to serve"
String bootstrap = resource("xds/bootstrap.json");
assertThat(GrpcXdsStartupGuard.bootstrapMismatches(profile, bootstrap)).isEmpty();

같은 자리에서 grpc-advanced-compat 은 넣지 않는다 — envoy.yaml 을 부분 문자열로만 확인하고 GrpcWebProxyContract 에 넣지 않는다(그 리프 §17.3). 두 리프가 같은 재료를 갖고 한 쪽만 고리를 닫았다.

16. 확인하지 못한 것

  • 실제 xDS 통제 평면을 세워 부트스트랩 대조를 재현하지 않았다.
  • 헤징 예산의 정상 상태 비율을 부하로 측정하지 않았다. 토큰 계산으로 판정했다.

17. 손볼 것

17.1 P3 — 부트스트랩 대조가 문서 어디든의 부분 문자열을 본다
if (!bootstrapJson.contains("\"xds_servers\"")) {  }
if (!bootstrapJson.contains("\"channel_creds\"") || !bootstrapJson.contains("\"tls\"")) {  }
if (!bootstrapJson.contains(profile.resourceNamespace())) {  }

세 검사가 모두 문서 전체에 대한 부분 문자열 포함이다. JSON 파서를 쓰지 않은 이유는 자바독이 밝힌다 — 세 필드를 보려고 파서를 xDS 를 켜는 모든 배포의 실행 클래스패스에 올리지 않겠다는 것이다. 그 판단 자체는 이 저장소의 다른 결정들과 일관된다.

다만 검사의 형태가 그 판단보다 느슨하다.

  • "tls" 가 문서 어디에든 있으면 통과한다. 통제 평면 채널이 insecure 로 설정되어 있고 다른 곳(예: 서버 리스너 설정)에 tls 라는 낱말이 있으면 두 번째 검사가 지나간다.
  • 자원 이름공간이 주석·다른 필드·다른 서버 항목에 있어도 통과한다. 세 번째 검사가 막으려는 것은 "이 클라이언트가 자기 이름공간 밖을 구독하는 것" 인데, 문자열이 어딘가에 있다는 것은 그것이 이 클라이언트의 구독 대상이라는 뜻이 아니다.

그리고 이 검사가 막으려는 실패는 자바독이 스스로 "조용하다" 고 적은 것이다 — 아무것도 오류가 되지 않는 종류다. 느슨한 검사와 조용한 실패의 조합이 이 항목을 기록하는 이유다.

수정은 파서를 들이지 않고도 가능하다 — "channel_creds" 를 포함하는 객체 범위 안에서 "type" 값을 찾는 정도의 구조 인식이면 두 번째 검사가 실제 조건에 가까워진다. 또는 파서를 테스트 범위에만 두고 이 가드는 형태를 좁힌 정규식으로 바꾼다.

17.2 P3 — 대체 선택기는 사용자 정의 선택기가 받는 보호를 받지 않는다
try {
  chosen = picker.pick(selectable);
} catch (RuntimeException pickerFailure) {
  return GrpcLoadBalancerDecision.fallback(fallback.pick(selectable), "…");
}
if (chosen == null || !selectable.contains(chosen)) {
  return GrpcLoadBalancerDecision.fallback(fallback.pick(selectable), "…");
}

fallback.pick(selectable) 은 감싸이지 않는다. 대체가 던지면 예외가 그대로 올라가고, 널이나 목록 밖 엔드포인트를 돌려주면 그대로 결정이 된다.

기본 생성자는 플랫폼의 라운드 로빈을 대체로 쓰므로 지금은 안전하다. 그러나 두 인자 생성자가 임의의 선택기를 대체로 받고, 그 인자에는 아무 제약이 없다.

이 클래스의 존재 이유가 "선택기 버그가 가용성이 아니라 균형을 저하시키게 하는 것" 인데, 대체 선택기의 버그는 가용성을 저하시킨다.

수정은 대체 호출도 같은 검사를 지나게 하거나(그 결과가 널이거나 목록 밖이면 플랫폼 라운드 로빈으로 한 번 더 떨어진다), 두 인자 생성자를 없애 대체를 플랫폼 것으로 고정하는 것이다.

17.3 P2 — 리졸버의 개정 가드가 비교 후 교체가 아니다

GrpcCustomResolver 의 javadoc 이 지키겠다고 하는 것은 명확하다.

"Stale revisions and empty endpoint sets are dropped rather than propagated."

빈 집합은 GrpcEndpointSnapshot 의 생성자가 지키므로 성립한다. 개정 가드는 그렇지 않다.

public List<String> offer(GrpcResolverUpdate update) {
  if (closed.get()) { return List.of(); }
  List<String> violations = GrpcResolverSafetyPolicy.violations(update, applied.get());   // ← 읽기
  if (!violations.isEmpty()) { return violations; }
  applied.set(update.snapshot());                                                          // ← 조건 없는 쓰기
  listener.accept(update);
  return List.of();
}

AtomicReference 를 쓰면서 읽기와 쓰기 사이에 원자성이 없다. 개정 5 와 6 을 든 두 스레드가 같은 applied(개정 4)를 읽으면 둘 다 supersedes 를 통과하고, 나중에 set 하는 쪽이 이긴다. 6 이 먼저 쓰이고 5 가 덮으면 채널이 옛 엔드포인트로 되돌아간다 — 개정 번호가 존재하는 이유가 정확히 그것을 막는 것이다.

listener.accept(update)set 밖에 있으므로, applied 의 최종 값이 옳더라도 리스너(=채널)가 받는 순서는 뒤집힐 수 있다. 채널은 마지막으로 받은 것을 믿는다.

같은 형태가 이 가족에 셋이다.

자리 형태
GrpcHedgingBudget.tryConsume(이 리프) 비교 후 교체 루프 — 정확
GrpcCredentialRotationManager.rotate·completeDrain(grpc-policy §17.2) 읽고 조건 없이 쓴다
GrpcChannelRuntimeRegistry.rotate(grpc-client) 같은 형태
GrpcCustomResolver.offer(여기) 같은 형태

정본이 같은 리프 안에 있다는 점이 §12.2 의 대조와 같다 — 이 리프는 예산에서는 CAS 를 쓰고 리졸버에서는 쓰지 않는다.

시험이 보지 못하는 이유. a stale revision is dropped rather than applied 는 단일 스레드에서 개정 2 를 적용한 뒤 개정 1 을 제시한다. 순차적으로는 가드가 정확히 작동한다.

등급. 이 리프가 배선되지 않으므로 P2. 리졸버는 정의상 외부 발견 소스가 밀어 넣는 것이고, 그 소스가 한 스레드만 쓴다는 보장은 이 클래스가 하지 않는다.

수정. applied.updateAndGet 안에서 판정과 교체를 함께 하거나, compareAndSet(observed, snapshot) 이 실패하면 다시 읽어 판정한다. 리스너 통지는 성공한 CAS 뒤에 그 CAS 가 이긴 순서로 해야 한다 — 예산 쪽의 tryConsume 루프가 같은 리프 안의 본보기다.

확인된 설계(문제 아님)
  • 헤징을 읽기 전용 단항으로 한정하고, 멱등 키가 왜 도움이 되지 않는지를 명시한 것.
  • 헤징 예산의 비율 상한 0.5 와 그 근거.
  • 예산 소비를 정확한 비교 후 교체로 구현한 것.
  • xDS 를 Stable 지원으로 광고할 수 없게 상수로 못박은 것.
  • 애플리케이션과 통제 평면이 재시도를 함께 정의하는 것을 시작 차단 사유로 둔 것.
  • 부트스트랩과 프로파일의 불일치를 검사 대상으로 삼은 것 — 두 문서를 다른 사람이 다른 저장소에서 쓴다.
  • 리졸버가 자격증명을 실을 수 없게 한 것과 권한 문자열 형태를 제한한 것.
  • 리졸버 업데이트의 개정 번호 전진을 요구한 것.
  • 선택기가 리졸버가 준 엔드포인트만 고르게 한 것.

Source anchors

src/grpc-advanced/grpc-advanced-resilience/build.gradle
main/java/…/resilience/(GrpcHedgingEligibility · GrpcHedgingBudget · GrpcHedgingPolicy · GrpcHedgingResult)
main/java/…/xds/(GrpcXdsStartupGuard · GrpcXdsFailurePolicy · GrpcXdsProfile · GrpcXdsResourceSnapshot)
main/java/…/discovery/(GrpcResolverSafetyPolicy · GrpcLoadBalancerSafetyPolicy · GrpcCustomResolver · GrpcLoadBalancerDecision · GrpcEndpointSnapshot · GrpcEndpointCandidate · GrpcResolverUpdate · GrpcLoadBalancerPicker)

A20-GRPC-ADVANCED-STREAMING. grpc-advanced-streaming

분석 중에는 grpc/GRPC-ADVANCED-STREAMING.md 파일이었다. 237줄.

grpc-advanced-streaming 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) — src/main production 14파일 833줄 + src/test 4파일 429줄 축자 통독 완료. §17.1·§17.2 를 독립적으로 재도출했고 둘 다 성립한다. STRUCTURAL_ONLYgradle.lockfile 하나. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc-advanced/grpc-advanced-streaming SSOT owner: grpc-advanced-streaming integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • allowed_dependencies: ["grpc-core-api", "grpc-policy", "grpc-advanced-bootstrap"]
  • runtime_memberships: [] — build-only
파일 LOC
GrpcClientMessageDeduplicator 123
GrpcDemandController 105
GrpcBidiSession · GrpcBidiSequenceTracker 77 · 62
GrpcBidiDirectionState · GrpcClientStreamResumeDecision · GrpcClientStreamCheckpoint 61 · 58 · 56
GrpcClientStreamPolicy · GrpcClientStreamSessionId · GrpcManualFlowControlPolicy 50 · 46 · 45
GrpcBidiResumeState · GrpcDemandDecision · GrpcClientStreamState · GrpcClientStreamMessage 42 · 40 · 36 · 32
test 4파일 429
Coverage ledger
scope count disposition reason
main/java/** 14 FULL_READ 833줄 전 본문
test/java/** 4 FULL_READ 429줄
build.gradle 1 FULL_READ 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-5
// The streaming shapes the Stable plan deliberately excludes: client streaming sessions with
// dedup/checkpoint/resume, bidirectional sessions with independent per-direction sequences, and the
// manual flow-control approval API.

2. 적용됨과 수신됨을 구분한다

GrpcClientStreamCheckpoint javadoc:

"Applied, not received. The distinction is the whole contract: the transport acknowledging a message means it reached the server's buffer, and a checkpoint means the application committed its effect. A resume that continues from a transport acknowledgement skips everything that was received and not yet applied when the connection died."

그리고 체크포인트는 뒤로 갈 수 없다 — 뒤로 가려는 시도는 두 기록자가 한 세션을 체크포인트하고 있다는 뜻이다.

3. 집합이 아니라 체크포인트

GrpcClientMessageDeduplicator javadoc:

"Checkpoint-based rather than a set of seen keys. A set grows without bound for the life of a session and answers 'have I seen this' — which is not quite the question. The question is 'has this been applied', and a monotonic applied-sequence answers it in constant space and survives the process restart that a set does not."

판정은 셋이다 — 이미 적용됨이면 재생, 다음 순번보다 앞서면 간극, 아니면 적용.

재개 판정은 두 겹이다. 제시한 호출자가 세션 소유자와 다르면 거절하고, 체크포인트가 없으면 새 세션으로 돌린다.

"the server holds no checkpoint for this session; resuming would leave its prefix either lost or applied twice, with nothing to tell which"

그리고 적용 기록의 자바독이 저장소 쪽 요구를 적는다 — 적용 효과와 체크포인트는 한 트랜잭션에 있어야 하며, 따로 커밋하면 효과는 내구적이고 체크포인트는 아닌 창이 생긴다.

4. 방향마다 독립된 순번

"the client's message 5 and the server's message 5 are unrelated events, and a shared counter makes a resume token from one side meaningless to the other — so a reconnect either skips or replays, depending on which side moved faster."

절반 닫기와 취소가 방향별로 따로 있다.

5. 수동 흐름 제어

승인이 record 의 필드이고 거짓이면 생성자가 거부한다.

"Approval is a field because this capability is granted per method, not per service. A method that reads a large result set benefits; the one next to it does not, and enabling both because they share a service is how the second one acquires a bug nobody was looking for."

수요 상한과 교착 감시가 필수다 — 상한 없는 request(n) 은 단계만 늘린 무제한 버퍼링이다.

감시견은 잠들지 않고 두 시각을 비교한다 — 마지막으로 수요를 요청한 때와 마지막으로 메시지가 움직인 때. 둘 다 시간 제한만큼 멈춰 있으면 교착이다.

10. 테스트 레인

네 테스트 429줄. 중복 제거 판정과 재개, 수요 상한과 교착, 방향별 순번, 클라이언트 스트림 정책 거부를 확인한다.

12. negative-space probes

12.1 도달성. Advanced 가족이므로 배선 경로가 없다. 리프 밖 참조도 없다.

12.2 대조군 — 동시성 규율. 이 리프는 가족 안에서 동시성을 가장 잘 다룬다.

클래스 보호
GrpcDemandController 모든 공개 메서드 synchronized
GrpcBidiSequenceTracker 모든 공개 메서드 synchronized
GrpcClientMessageDeduplicator ConcurrentHashMap

특히 GrpcDemandController.messageReceivedif (outstandingDemand > 0) outstandingDemand--;synchronized 안이라 경합하지 않는다. 같은 형태가 grpc-serverGrpcAdmissionController.releasegrpc-clientGrpcChannelRuntime.finishUnaryCall 에서는 보호 없이 쓰여 각각 결함이 된다.

12.4 드리프트. build.gradle 이 서술한 세 요소가 전부 존재한다.

16. 확인하지 못한 것

  • 실제 스트림을 열어 재개를 재현하지 않았다. 배선 경로가 없다.
  • replayableOutcomes 의 증가를 장시간 실행으로 측정하지 않았다(§17.1). 제거 경로 부재로 판정했다.

17. 손볼 것

17.1 P3 — 클래스가 비판한 무제한 증가를 형제 맵이 그대로 한다

클래스 javadoc 이 집합 방식을 거부한 이유가 무제한 증가다 — "A set grows without bound for the life of a session".

체크포인트는 그 비판을 지킨다. 세션당 항목 하나이고 순번만 앞으로 간다.

형제 맵은 지키지 않는다.

private final ConcurrentMap<String, String> replayableOutcomes = new ConcurrentHashMap<>();

public void recordApplied(GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
  checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at));
  if (outcomeReference != null && !outcomeReference.isBlank()) {
    replayableOutcomes.put(message.dedupKey(), outcomeReference);   // ← 메시지마다 한 항목
  }
}

제거는 endSession 뿐이고, 그때 그 세션의 접두를 가진 키를 전부 지운다.

그러므로 결과 참조를 기록하는 세션에서는 적용된 메시지 수만큼 항목이 쌓인다. 상한도 만료도 없다.

클래스 javadoc 은 다르게 말한다.

"Replayed outcomes are kept for the small window after the checkpoint, so a duplicate that arrives before the checkpoint advances gets the original answer rather than being reapplied."

작은 창이 코드에 없다. 체크포인트가 앞으로 가도 그 이전 결과들은 남는다.

그리고 실제로 필요한 창은 좁다 — 판정이 alreadyApplied(sequence) 로 재생을 결정하고, 재생 응답에 쓰이는 것은 그 순번의 결과 하나다. 체크포인트보다 한참 뒤처진 순번의 결과가 필요할 상황은 재개 직후의 좁은 구간뿐이다.

수정은 창을 실제로 만드는 것이다 — 세션당 최근 N개만 유지하거나, 체크포인트가 앞으로 갈 때 그보다 오래된 항목을 지운다. 후자가 자바독의 서술과 정확히 같다.

17.2 P3 — 클라이언트 스트림 정책의 네 상한 중 둘은 읽는 코드가 없다

GrpcClientStreamPolicy javadoc 이 네 상한을 모두 든다.

"all four bounds are about the client rather than the server: how long it may hold the stream, how long it may go quiet, how fast it may send, and how much it may have unacknowledged."

저장소 전체에서 접근자 호출을 세면 둘이 0 이다.

maxMessagesPerSecond   production 호출 0
maxInFlightMessages    production 호출 0
wholeStreamRetryAllowed production 호출 0

Advanced 가족이 미배선이라는 사실과는 별개다 — 이 리프 안에도 그 값을 쓰는 코드가 없다. 수요 상한을 강제하는 GrpcDemandControllerGrpcManualFlowControlPolicy 를 쓰고, 이 정책을 보지 않는다.

wholeStreamRetryAllowed() 는 항상 거짓을 돌려주는 형태이므로 그 자체가 문서화 장치다. 나머지 둘은 강제 지점이 필요하다.

수정은 상한을 강제하는 지점을 만들거나(수신 경로에 속도·미확인 수 검사), 강제되지 않는 값이 강제되는 것처럼 읽히지 않도록 자바독을 낮추는 것이다.

17.3 P3 — 체크포인트 전진이 ConcurrentMap 위의 확인 후 쓰기다

GrpcClientStreamCheckpoint.advancedTo 가 뒤로 가는 것을 거부하고, 그 메시지가 원인을 정확히 짚는다 — "two writers are checkpointing one session". 그 가드가 보는 것은 호출한 스레드가 읽은 값 이다.

public void recordApplied(GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
  GrpcClientStreamCheckpoint checkpoint = requireCheckpoint(message.sessionId());   // ← 읽기
  checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at));  // ← 조건 없는 쓰기
  

두 스레드가 순번 5 와 6 을 적용하며 같은 체크포인트(4)를 읽으면 둘 다 advancedTo 를 통과한다. 5 를 든 쪽이 나중에 put 하면 체크포인트는 6 에서 5 로 뒤로 간다advancedTo 가 막겠다고 한 바로 그 상태이고, 이번에는 예외 없이 조용히 일어난다.

그러면 순번 6 의 메시지가 다시 APPLY 로 판정되어 두 번 적용된다. 이 클래스가 존재하는 이유가 정확히 그것을 막는 것이다.

ConcurrentHashMap 에는 이 형태를 위한 연산이 있다.

checkpoints.compute(key, (k, existing) -> existing.advancedTo(message.sequence(), at));

compute 안에서는 읽기와 쓰기가 원자적이므로, 뒤처진 쪽이 advancedTo 의 예외를 실제로 받는다 — 가드가 설계대로 발화한다.

대조. 같은 리프의 GrpcDemandController 는 모든 공개 메서드가 synchronized 이고, GrpcBidiSequenceTracker 도 그렇다(§12.2 가 그것을 이 가족의 모범으로 든다). 중복 제거기만 ConcurrentMap 의 원자 연산을 쓰지 않는다.

시험이 보지 못하는 이유. 중복 제거기 시험 아홉 개가 전부 단일 스레드다. 순차적으로는 advancedTo 가 정확히 작동하고, 전용 시험(aCheckpointRecordsWhatWasApplied)이 그것을 확인한다 — 확인하는 것은 record 의 메서드이지 맵에 쓰는 경로가 아니다.

등급. 미배선이므로 P3. 다만 이 클래스의 javadoc 이 "The application effect and this checkpoint belong in one transaction" 이라고 적어 둔 것과 함께 보면, 이 자리는 배선되는 날 트랜잭션 경계와 함께 다시 설계될 곳이다.

확인된 설계(문제 아님)
  • 적용됨과 수신됨을 구분하고 그 차이를 계약으로 삼은 것.
  • 집합 대신 단조 증가 순번으로 상수 공간을 쓴 것.
  • 체크포인트가 뒤로 가려는 시도를 두 기록자의 신호로 읽는 것.
  • 재개에서 소유자 불일치를 거절하고, 체크포인트 부재를 새 세션으로 돌리는 것.
  • 적용 효과와 체크포인트를 한 트랜잭션에 두라는 요구를 자바독에 남긴 것.
  • 방향별 순번을 합치지 않은 것과 그 근거.
  • 수동 흐름 제어 승인을 메서드 단위 필드로 둔 것.
  • 감시견이 잠들지 않고 두 시각을 비교하는 것.
  • 전체 스트림 재시도를 설정이 아니라 상수 거절로 둔 것.
  • 동시성 보호를 실제로 적용한 것 — 가족의 다른 리프와 대조된다.

Source anchors

src/grpc-advanced/grpc-advanced-streaming/build.gradle
main/java/…/streaming/GrpcClientMessageDeduplicator.java:1-123
main/java/…/streaming/GrpcDemandController.java:1-105
main/java/…/streaming/GrpcBidiSequenceTracker.java:1-62
main/java/…/streaming/GrpcClientStreamCheckpoint.java:1-56
main/java/…/streaming/GrpcClientStreamPolicy.java:1-50
main/java/…/streaming/GrpcManualFlowControlPolicy.java:1-45
main/java/…/streaming/GrpcClientStreamMessage.java:1-32
main/java/…/streaming/(GrpcBidiSession · GrpcBidiDirectionState · GrpcBidiResumeState · GrpcClientStreamResumeDecision · GrpcClientStreamSessionId · GrpcDemandDecision · GrpcClientStreamState)
test/java/…/streaming/(GrpcClientMessageDeduplicatorTest · GrpcDemandControllerTest · GrpcBidiSequenceTrackerTest · GrpcClientStreamPolicyTest)

A20-GRPC-CLIENT. grpc-client

분석 중에는 grpc/GRPC-CLIENT.md 파일이었다. 246줄.

grpc-client 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) — src/main production 13파일 931줄 + src/test 4파일 581줄 축자 통독 완료. 재통독에서 §17.1–§17.4 를 독립적으로 재도출했고 넷 다 성립한다. STRUCTURAL_ONLYgradle.lockfile 하나. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-client SSOT owner: grpc-client integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • allowed_dependencies: ["grpc-core-api", "grpc-policy"] + vendor grpc-api·grpc-stub(BOM)
  • runtime_memberships: [] — build-only
파일 LOC
GrpcChannelRuntimeRegistry 137
GrpcTypedStubFactory 119
GrpcNamedChannelProfile 100
GrpcChannelRuntime 96
GrpcClientMetadataPolicy 87
GrpcChannelProfileValidator 81
GrpcStubPolicyApplier · GrpcClientCallContext 68 · 67
GrpcChannelGeneration · GrpcLoadBalancingPolicy · GrpcChannelDrainPolicy · GrpcStubDescriptor · GrpcCallCredentialProvider 42 · 35 · 34 · 33 · 32
test 4파일 581
Coverage ledger
scope count disposition reason
main/java/** 13 FULL_READ 931줄 전 본문
test/java/** 4 FULL_READ 581줄
build.gradle 1 FULL_READ 17줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-4
// Client runtime: named channel profiles, channel runtime generations with drain, the typed stub
// factory that refuses to hand a raw Channel to application code, and client metadata/credentials.

2. 채널은 한 번 만들고 재사용한다

레지스트리 javadoc 이 두 성질을 든다.

"a channel is created once and reused. Creating one per request is a mistake that works — every call succeeds — while spending a TCP handshake, a TLS handshake and an HTTP/2 setup on each one, and it is usually found by a connection count rather than by a failure."

"prepare-then-swap. The new runtime exists before the pointer moves, so no call ever finds nothing there; the old one drains rather than being closed under its in-flight work."

require 가 빈 값을 돌려주지 않고 던지는 이유도 적혀 있다 — 빈 값은 "설정되지 않음" 과 "설정됐지만 도달 불가" 를 구분할 수 없게 만든다.

3. 세대와 배수

GrpcChannelRuntime 이 단항 호출과 열린 스트림을 따로 센다.

"a drain treats them differently: unary calls are waited for, streams are signalled. A single counter would make the drain either cut a stream that could have finished or wait an hour for one that never will."

그리고 기저 채널을 노출하지 않는다 — 그것을 건네는 것이 정책 없는 스텁이 만들어지는 경로다.

4. 타입 있는 스텁 공장 — 두 거절

"It will not build a stub type nobody registered, so a service cannot acquire a channel without a policy; and it never returns a Channel or a builder, so application code has no way to construct one itself. Both are what make the raw-API import rule enforceable rather than merely stated: there is nothing to reach for."

등록 함수가 원시 채널이 아니라 런타임을 받는 것도 같은 이유다 — 등록이 채널을 몰래 빼돌릴 수 없다.

빈 공장은 만들 수 없다 — "a stub factory with no registered types can build nothing and refuses everything."

5. 메타데이터 허용 목록이 둘인 이유

"Tenant and actor metadata is meaningful to a service inside the same trust domain and is an unverified assertion to one outside it; sending it across the boundary invites the receiver to trust it."

그리고 교차 경계 목록은 같은 도메인 목록의 부분집합이어야 한다 — 생성자가 강제한다.

인가 헤더는 어느 목록에도 올 수 없다.

"authorization is supplied per call by a credential provider, not set as metadata; a header set by the application is a header that survives a rotation."

나가는 방향은 허용 목록 밖을 거절이 아니라 폐기로 다룬다. 그 비대칭의 이유도 적혀 있다 — 알 수 없는 상관 헤더 때문에 나가는 호출이 실패하는 것이 더 나쁜 결과다. 예산은 그대로 강제된다.

10. 테스트 레인

네 테스트 581줄. 프로파일 검증, 레지스트리 설치·회전·배수, 메타데이터 정책, 스텁 공장의 두 거절을 확인한다.

12. negative-space probes

12.1 도달성. build-only. grpc-spring-boot-starter 는 이 리프의 타입을 빈으로 만들지 않는다(§20 가족 문서 §3.4).

12.2 대조군 — 비원자적 해제. 이 리프의 finishUnaryCall·closeStreamgrpc-serverGrpcAdmissionController.release, grpc-policyGrpcStreamAdmission.release 가 같은 형태다 — get() > 0 을 본 뒤 별도로 감소. §17.2 가 이 리프에서의 구체적 결과를 다룬다.

12.3 이 리프를 import 하는 곳. 재통독에서 다시 세었다. grpc-discovery main 셋(GrpcResolverProfile·GrpcStableLoadBalancer·GrpcKubernetesRoutingMode)과 grpc-spring-boot-starterGrpcPlatformStartupValidator 가 이 리프의 타입을 이름으로 부른다 — 빈으로 만들지는 않고 검증·판정에 쓴다. GrpcTypedStubFactory·GrpcChannelRuntimeRegistry 를 실제로 조립하는 코드는 없다.

12.4 드리프트. build.gradle 이 서술한 네 요소가 전부 존재한다. 드리프트 없음.

16. 확인하지 못한 것

  • 실제 채널을 만들어 회전시키지 않았다. ManagedChannel 을 만드는 코드가 이 리프에 없다.
  • 동시 회전과 동시 해제를 실행으로 재현하지 않았다. 원자성 분석으로 판정했다.

17. 손볼 것

17.1 P2 — rotate 가 비교 후 교체가 아니라 덮어쓰기다

install 은 정확하다.

if (!holder.compareAndSet(null, runtime)) {
  throw new IllegalStateException("… already has a runtime; use rotate()");
}

rotate 는 그렇지 않다.

GrpcChannelRuntime previous = holder.get();
if (!previous.generation().supersededBy(next)) { throw ; }
GrpcChannelRuntime replacement = new GrpcChannelRuntime(next);
holder.set(replacement);              // ← 비교 없이 덮어쓴다
previous.beginDrain();
draining.computeIfAbsent().add(previous);

두 회전이 동시에 들어오면 둘 다 같은 previous 를 읽고, 둘 다 대체본을 만들고, 나중 set 이 앞의 대체본을 덮는다.

덮인 대체본은 어디에도 등록되지 않는다 — draining 목록에 들어가는 것은 previous 뿐이다. 그러므로 그 세대는 배수도 회수도 되지 않고, 그 위에서 시작된 호출은 아무도 세지 않는다.

클래스가 이 문제를 인지하고 있다는 증거가 같은 파일에 있다 — install 의 비교 후 교체와 AtomicReference 선택이다. 회전 쪽만 그 규율에서 벗어나 있다.

수정은 holder.compareAndSet(previous, replacement) 로 바꾸고 실패 시 다시 읽어 판정하거나 던지는 것이다.

17.2 P2 — 비원자적 감소가 세대를 영구히 회수 불가로 만든다
public void finishUnaryCall() {
  if (inFlightUnaryCalls.get() > 0) { inFlightUnaryCalls.decrementAndGet(); }
}
public void closeStream() {
  if (openStreams.get() > 0) { openStreams.decrementAndGet(); }
}

카운터가 1 일 때 두 스레드가 동시에 끝나면 둘 다 조건을 통과해 둘 다 감소시켜 −1 이 된다.

그 결과가 이 리프에서는 구체적이다.

public boolean quiescent() {
  return inFlightUnaryCalls.get() == 0 && openStreams.get() == 0;
}

정확히 0 을 요구한다. 음수가 되면 조용해짐 판정이 영원히 거짓이고, retireQuiescent 가 그 세대를 결코 제거하지 않는다. 회전이 반복될수록 draining 목록이 자란다.

같은 형태가 이 가족의 다른 두 곳에도 있다(GrpcAdmissionController.release, GrpcStreamAdmission.release). 그쪽은 경계가 느슨해지는 결과였고, 이쪽은 자원이 회수되지 않는 결과다.

수정은 updateAndGet(v -> Math.max(0, v - 1)) 이나 decrementAndGet() 후 하한 보정이다. 같은 가족의 GrpcRetryBudget 이 정확한 비교 후 교체 루프를 이미 쓴다.

17.3 P3 — 배수 목록의 순회가 동기화 밖에서 일어난다
draining.computeIfAbsent(name, key -> java.util.Collections.synchronizedList(new ArrayList<>())).add(previous);

public List<GrpcChannelRuntime> draining(GrpcChannelProfileName profileName) {
  return List.copyOf(draining.getOrDefault(profileName, List.of()));
}
public int retireQuiescent(GrpcChannelProfileName profileName) {
  List<GrpcChannelRuntime> runtimes = draining.get(profileName);
  
  List<GrpcChannelRuntime> quiescent = runtimes.stream().filter(GrpcChannelRuntime::quiescent).toList();
  runtimes.removeAll(quiescent);

Collections.synchronizedList 는 개별 연산만 동기화한다. 순회는 호출자가 그 목록을 잠그고 해야 한다는 것이 그 API 의 계약이다.

List.copyOf(...)stream() 둘 다 순회다. 회전이 동시에 add 하면 동시 변경 예외가 가능하다.

그리고 읽고 지우는 두 단계가 원자적이지 않으므로, 그 사이에 조용해진 세대가 추가되면 이번 회수에서 빠진다. 후자는 다음 호출에서 회수되므로 무해하다.

수정은 CopyOnWriteArrayList 로 바꾸는 것이다. 배수 목록은 쓰기가 드물고 읽기가 잦아 그 자료구조의 전형적 용례다.

17.4 P3 — 프로파일 검증기가 javadoc 이 든 두 실수 중 하나만 검사한다

javadoc:

"Two in particular. Round-robin over a target that resolves to one address … and two profiles pointing at the same target with the same settings are one channel with two names, which is the shape that appears when somebody wanted a different SLO and copied the profile instead."

구현된 것은 첫째와 다른 것이다.

String previous = seenNames.putIfAbsent(profileName, profile.target().toString());
if (previous != null) { violations.add("channel profile '…' is declared twice, for '…' and '…'"); }

이름이 같은 프로파일이 두 번 선언된 경우를 잡는다. javadoc 이 든 둘째는 이름이 다르고 대상이 같은 경우인데, 그 검사가 없다. 지도는 이름을 키로 쓰므로 같은 대상을 가리키는 두 이름은 서로를 만나지 않는다.

그리고 둘째가 실제로 더 찾기 어려운 형태다 — 이름이 같으면 설정 결속이 먼저 실패하거나 나중 것이 이기지만, 이름이 다르면 조용히 두 채널이 생긴다.

수정은 대상과 설정을 키로 하는 두 번째 지도를 두고 역방향 중복을 보고하는 것이다.

확인된 설계(문제 아님)
  • 채널을 한 번 만들고 재사용하는 것과, 그 실수가 실패가 아니라 연결 수로 발견된다는 근거.
  • 준비 후 교체 — 새 런타임이 먼저 존재하고 포인터가 나중에 움직인다.
  • require 가 빈 값 대신 던지는 것.
  • 단항 호출과 스트림을 따로 세는 것.
  • 기저 채널을 노출하지 않는 것과 등록 함수가 런타임을 받는 것.
  • 등록되지 않은 스텁 타입을 거절하는 것.
  • 신뢰 도메인별 메타데이터 허용 목록 둘과 부분집합 불변식.
  • 인가 헤더를 자격증명 제공자에게만 맡기는 것.
  • 나가는 방향에서 허용 목록 밖을 폐기로 다루고 그 비대칭의 이유를 적은 것.

Source anchors

src/grpc/grpc-client/build.gradle:1-17
main/java/…/client/GrpcChannelRuntimeRegistry.java:1-137
main/java/…/client/GrpcTypedStubFactory.java:1-119
main/java/…/client/GrpcNamedChannelProfile.java:1-100
main/java/…/client/GrpcChannelRuntime.java:1-96
main/java/…/client/GrpcClientMetadataPolicy.java:1-87
main/java/…/client/GrpcChannelProfileValidator.java:1-81
main/java/…/client/(GrpcStubPolicyApplier · GrpcClientCallContext · GrpcChannelGeneration · GrpcLoadBalancingPolicy · GrpcChannelDrainPolicy · GrpcStubDescriptor · GrpcCallCredentialProvider)
test/java/…/client/(GrpcNamedChannelProfileTest · GrpcClientMetadataPolicyTest · GrpcTypedStubFactoryTest · GrpcChannelRuntimeRegistryTest)

A20-GRPC-CODEGEN. grpc-codegen

분석 중에는 grpc/GRPC-CODEGEN.md 파일이었다. 348줄.

grpc-codegen 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 9파일 692줄, test 3파일 414줄, 소비자 픽스처 리소스 2파일 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-codegen SSOT owner: grpc-codegen integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • allowed_dependencies: ["grpc-core-api", "grpc-proto-contract"]
  • runtime_memberships: [] — build-only
파일 LOC
GrpcConsumerFixture 158
GrpcSchemaArtifactPublisher 113
GrpcCodegenManifest 81
GrpcBufPolicy 75
GrpcGeneratedPackagePolicy 68
GrpcDescriptorArtifact 59
GrpcCodegenOutput 58
GrpcBreakingCategory 43
GrpcSchemaBaseline 37
main 합계 692
test 3파일 248 + 88 + 78
소비자 픽스처 리소스 33 + 17
Coverage ledger
scope count disposition reason
main/java/** 9 FULL_READ 692줄 전 본문
test/java/** 3 FULL_READ 414줄 · 테스트 21개
test/resources/consumer-fixtures/** 2 FULL_READ 50줄
build.gradle 1 FULL_READ 12줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-7
// Contract governance: Buf format/lint/breaking policy, the single codegen owner declaration, and
// the descriptor/schema-hash release artifact with its consumer-compile gate.
//
// The Buf rules are implemented here rather than shelled out to the Buf CLI (adaptation D5): the
// CLI is not present in this toolchain, and a gate that silently no-ops when a binary is missing is
// worse than one that computes the same judgement from the committed schema.

마지막 문장이 이 저장소의 반복 원칙이다 — 도구가 없을 때 조용히 통과하는 게이트는 없는 것보다 나쁘다.

2. 파괴적 변경 범주 — 왜 FILE 인가

범주 소스 파괴 감지 파일 이동 감지
FILE
PACKAGE 아니오
WIRE_JSON 아니오 아니오
WIRE 아니오 아니오

"A team that gates on WIRE ships a field rename, watches its own integration tests pass, and finds out at the consumer's next build."

GrpcBufPolicy 정규 생성자가 소스 파괴를 감지하지 못하는 범주를 거부하고, 형식·린트를 선택 사항으로 두지 않는다.

3. 기준선은 브랜치가 아니라 릴리스다

GrpcSchemaBaseline-SNAPSHOT 버전을 거부하고 sha256: 접두 해시를 요구한다.

"Comparing against the previous commit answers 'did this commit break anything', which is not the question: a breaking change introduced two commits ago and refined since then passes every commit-to-commit check while being broken against everything that has actually been deployed."

4. 생성물의 자리

GrpcCodegenOutput 은 모든 경로가 빌드 디렉터리 아래일 것을 요구하고, 절대 경로와 .. 를 거부하며, 서술자 집합 확장자를 .desc/.binpb 로 제한한다.

"A generator that writes into a source tree produces files that get committed, then edited, then silently reverted by the next regeneration — and the diff that reverts them looks like the generator working correctly."

5. 생성자는 하나여야 한다

GrpcCodegenManifest 는 소유자 하나와 관리 플랫폼에서 오는 두 버전 출처를 요구한다.

"Two generators for one schema is the state in which a type exists twice with different options and the classpath decides which one a consumer gets."

"A pinned protobuf version beside a BOM-managed gRPC version is how the runtime and the generator drift into a combination nobody tested, and the symptom is a NoSuchMethodError in generated code."

그리고 생성 패키지와 손으로 쓴 패키지가 겹치면 생성 시점에 던진다.

caSkeleton() 의 소유자는 Gradle protobuf 플러그인이고, javadoc 이 그것이 아직 이 빌드에서 돌지 않는다고 적는다 — "this manifest is what a future decision to turn it on has to satisfy rather than replace."

6. 소비자 컴파일 게이트

GrpcConsumerFixture.fromJavaSource 가 릴리스된 소비자의 자바 소스에서 요구 사항 셋을 기계적으로 유도한다.

"a hand-written requirement list is a second copy of what the client already says and the copy is the one that stops being updated."

세 규칙이다.

생성 자바 패키지 = fixture 클래스가 import 하는 패키지 중 접미가 맞는 것
서비스          = <Name>Grpc import → <proto package>.<Name>
메서드          = stub.<name>( 호출 → <service>/<UpperCamelName>

그리고 그것이 컴파일의 근사라는 것과, 근사인 이유(ADR-GRPC-002)를 함께 적는다.

breaksAgainst 는 세 종류를 따로 보고한다 — 서비스 경로, 메서드 경로, 자바 패키지. 하나의 개수로 합치지 않는다.

10. 테스트 레인

세 테스트 414줄 · 21개.

GrpcBufPolicyTest 6개 — Stable 게이트가 FILE 이라는 것, WIRE/WIRE_JSON 거부, 형식·린트 비선택, 수명주기 태스크 이름(§17.1), 기준선의 불변 릴리스 요구, 해시 일치.

GrpcCodegenManifestTest 5개 — 소유자 유일성, 리터럴 버전 거부, 출력 경로가 build/ 아래여야 한다는 것과 서술자 확장자, 패키지 겹침의 양방향 감지.

GrpcDescriptorArtifactTest 10개 — 산출물의 불변 버전과 세 digest, 파괴 종류별 보고, 넓어진 스키마가 아무것도 깨지 않는다는 것, 커밋된 픽스처의 유도 결과, 메서드 이름 변경이 발행을 막는다는 것, 요구가 빈 픽스처 거부, 픽스처 build 파일의 고정 버전, 소비자 실패의 발행 차단, 같은 버전 다른 바이트 거부, 같은 바이트 재발행 허용.

픽스처를 리소스에서 읽는다. resource(path) 가 클래스로더로 consumer-fixtures/v1/... 를 읽어 실제 커밋된 텍스트를 넣는다 — 유도 규칙을 리터럴 문자열이 아니라 저장소에 있는 파일에 대고 돌린다. theFixturePinsItsSchemaVersion 은 픽스처의 build.gradle.kts 본문까지 대조한다.

12. negative-space probes

12.1 도달성. build-only 이지만 타입 참조는 리프 밖에 있다. 실제 참조 지점은 다섯이다.

참조 형태
grpc-testkit/…/release/GrpcStableReleaseGate.java:3 import …codegen.GrpcSchemaArtifactPublisher — production src/main 코드
grpc-testkit/build.gradle:55 api project(':grpc:grpc-codegen')
grpc-spring-boot-starter/build.gradle:19 implementation project(':grpc:grpc-codegen')
grpc-proto-contract/…/GrpcProtoContractValidator.java:22 javadoc 언급만
grpc-core-api/…/GrpcStableModuleCatalog.java:24 목록 안의 "grpc-codegen" 문자열

GrpcStableReleaseGate.evaluateGrpcSchemaArtifactPublisher.PublishDecision인자로 받는다 — 발행자를 만들지 않는다. 그리고 그 게이트 자신도 리터럴을 먹이는 테스트 말고는 호출자가 없다(grpc-testkit §17). 즉 타입 수준 연결은 실재하지만 그 사슬 어디에도 실행 시점 생산자가 없다.

grpc-spring-boot-starter 의 의존 선언에는 대응하는 자바 참조가 없다 — 스타터 소스 전체에 codegen 문자열이 나오지 않는다. 쓰이지 않는 의존이다.

12.2 저장소의 스키마에는 service 가 하나도 없다.

$ grep -rn "^service" --include=*.proto src/     (매치 없음)
$ grep -rn "^package" --include=*.proto src/
  grpc-proto-contract/…/v1/stream.proto:3: package hyeonworks.grpc.common.v1;
  grpc-proto-contract/…/v1/error.proto:3:  package hyeonworks.grpc.common.v1;
  grpc-advanced-edition/…/edition2024/compatibility.proto:3: package hyeonworks.grpc.edition.v1;
  messaging-schema-protobuf/src/test/proto/order_created_v1.proto:3: package dev.caskeleton.messaging.sample;

두 실물 proto 는 message 와 enum 만 담는다. 그런데 GrpcDescriptorArtifact 정규 생성자는 메서드가 비면 거부한다 — "a schema artifact with no methods describes nothing". 이 산출물 타입은 이 저장소의 실제 스키마를 표현할 수 없다.

그래서 소비자 게이트 전체가 저장소에 없는 표면(hyeonworks.document.v1.DocumentService)을 상대로만 돌아간다. GrpcCodegenManifest.caSkeleton() 이 선언하는 생성 패키지는 hyeonworks.grpc.common.v1.generated 이고 픽스처가 유도하는 패키지는 hyeonworks.document.v1.generated 다 — 매니페스트와 픽스처가 서로 다른 스키마를 서술한다.

결함으로 세지 않는 이유는 build.gradle 과 매니페스트 javadoc 이 이 리프를 "protoc 을 켜기로 하는 미래의 결정이 만족시켜야 할 선언"으로 규정하기 때문이다(D6). 다만 §17.1·§17.4 의 검사들이 지금 무엇에 대해서도 돌지 않는다는 사실의 뿌리가 여기다.

12.3 도달 불가 분기. GrpcSchemaArtifactPublisher.evaluate 의 두 번째 차단 사유는 발화할 수 없다.

if (!policy.breakingCategory().detectsSourceBreak()) {
  blockers.add("the active breaking category does not detect source breaks");
}

GrpcBufPolicy 정규 생성자가 이미 그런 범주를 거부하므로, 구성된 정책은 언제나 소스 파괴를 감지한다. 이 저장소에서 반복해서 나타나는 형태다 — 선행 검증이 후행 검증을 가린다. 보안 효과는 그대로이므로 결함이 아니라 기록으로 남긴다.

12.4 드리프트. build.gradle 이 서술한 세 요소(Buf 정책·단일 생성자 선언·서술자 산출물과 소비자 게이트)가 전부 존재한다. 드리프트 없음.

16. 확인하지 못한 것

  • 실제 protoc 이나 Buf CLI 를 돌리지 않았다. 저장소에 둘 다 없다.
  • 서비스가 둘 이상인 픽스처를 만들어 §17.3 을 재현하지 않았다. 유도 코드로 판정했다.
  • §17.4 의 어긋난 짝(publish(다른 후보, 이 결정))을 실제로 실행해 보지 않았다. publish 본문에 대조 코드가 없다는 것으로 판정했다.
  • 테스트를 실행하지 않았다. 21개 전부 본문으로만 확인했다.
  • grpc-spring-boot-starter 가 이 모듈을 의존 선언만 하고 쓰지 않는 것은 문자열 grep 으로 판정했다 — 그쪽 SSOT 에서 다시 본다.

17. 손볼 것

17.1 P3 — Buf 수명주기 태스크 목록이 빌드와 대조되지 않는다. 테스트는 목록을 자기 자신과 비교한다

정책이 네 태스크 이름을 담고, javadoc 이 그 이유를 적는다.

"Keeping the task names in the policy rather than only in a workflow file means a missing stage is a test failure rather than a stage nobody noticed was gone."

그런데 그 네 이름은 저장소의 어떤 빌드 파일에도 없다.

$ grep -rn "bufFormatCheck\|bufLint\|bufBreaking" --include=*.gradle src/
(매치 없음)

그리고 테스트가 비교하는 대상이 실제 등록 태스크 집합이 아니다.

assertThat(GrpcBufPolicy.requiredTasks())
    .containsExactly("bufFormatCheck", "bufLint", "bufBuild", "bufBreaking");
assertThat(GrpcBufPolicy.missingTasks(Set.of("bufFormatCheck", "bufLint", "bufBuild")))
    
assertThat(GrpcBufPolicy.missingTasks(Set.copyOf(GrpcBufPolicy.requiredTasks()))).isEmpty();

첫 단언은 목록을 리터럴과, 셋째는 목록을 자기 자신과 비교한다. 어느 것도 빌드가 그 단계를 등록했는지 묻지 않는다.

Buf CLI 가 이 툴체인에 없다는 것은 build.gradle 이 이미 밝힌 사실이므로 태스크가 없는 것 자체는 놀랍지 않다. 어긋난 것은 javadoc 의 주장이다 — 지금 형태에서 단계가 사라져도 테스트는 초록이다.

수정은 missingTasks 에 Gradle 이 실제로 등록한 태스크 이름 집합을 넣는 검사를 만들거나(다른 가족의 레인 등록 검사와 같은 형태), CLI 가 없는 동안에는 그 문장을 "CI 환경이 채울 계약" 으로 낮추는 것이다.

17.2 P3 — 릴리스 버전 불변성이 프로세스 안에서만 성립한다
private final Map<String, String> publishedHashesByVersion = new LinkedHashMap<>();

String alreadyPublished = publishedHashesByVersion.get(candidate.schemaVersion());
if (alreadyPublished != null && !alreadyPublished.equals(candidate.schemaHash())) {
  blockers.add("version '…' is already published with a different schema hash; a released schema version is immutable");
}

발행 이력이 발행자 인스턴스의 필드다. 새 프로세스는 아무것도 기억하지 못하므로 같은 버전을 다른 해시로 다시 발행하려는 시도가 통과한다.

이 클래스가 존재하는 이유가 그 규칙이다 — "refuses to let a released version change underneath its consumers." 그 규칙이 지켜지는 범위가 한 발행자 인스턴스의 수명이다.

빌드마다 새 프로세스가 도는 것이 정상 형태이므로, 실제로 이 검사가 무언가를 막으려면 이력이 산출물 저장소나 파일에서 와야 한다. GrpcSchemaBaseline 이 이미 릴리스된 해시를 들고 있으므로 그 방향의 재료는 있다.

덧붙여 이 맵은 동기화되지 않는다. 발행자를 공유해 병렬로 평가하면 경합한다.

17.3 P3 — 픽스처의 메서드 경로가 서비스 × 메서드 교차곱이다
while (calls.find()) {
  String method = calls.group(1);
  String upperCamel = Character.toUpperCase(method.charAt(0)) + method.substring(1);
  servicePaths.forEach(service -> methodPaths.add(service + "/" + upperCamel));
}

stub.<name>( 호출 하나가 그 파일이 import 한 모든 서비스에 대해 메서드 경로를 만든다.

javadoc 의 규칙 서술은 단수형이다 — "a method is a stub.<name>( call, mapped to <service>/<UpperCamelName>". 서비스가 둘 이상일 때 어느 서비스인지는 소스 텍스트만으로 알 수 없고, 코드는 전부에 붙이는 쪽을 골랐다.

결과는 존재하지 않는 메서드 경로를 요구하는 픽스처다. 서비스 둘과 메서드 셋이면 요구 경로가 여섯 개가 되고, 그중 셋은 어떤 후보 스키마에도 없으므로 breaksAgainst 가 항상 METHOD_PATH 파괴를 보고한다. 그러면 GrpcSchemaArtifactPublisher.evaluate 가 모든 발행을 거부한다.

커밋된 픽스처는 서비스가 하나(DocumentServiceGrpc)라 지금은 정확하다. 두 번째 소비자 픽스처를 추가하는 순간 성립한다.

수정은 호출자 변수의 선언 타입을 함께 읽어 메서드를 서비스에 귀속시키거나, 서비스가 둘 이상인 픽스처를 거부하는 것이다. 후자는 지금 형태의 근사를 명시적으로 만든다.

17.4 P2 — publish 가 결정을 그 결정이 판정한 후보에 묶지 않는다
public void publish(GrpcDescriptorArtifact candidate, PublishDecision decision) {
  if (decision == null || !decision.allowed()) {
    throw new IllegalStateException("refusing to publish '…'");
  }
  publishedHashesByVersion.put(candidate.schemaVersion(), candidate.schemaHash());
}

decisioncandidate 를 판정한 결정인지 확인하는 코드가 없다. PublishDecision(boolean allowed, List<String> blockers) 뿐이라 자기가 무엇을 판정했는지 들고 있지도 않다.

그래서 이렇게 쓸 수 있다.

PublishDecision ok = publisher.evaluate(harmlessArtifact, List.of());  // 통과
publisher.publish(breakingArtifact, ok);                               // 그대로 기록된다

두 번째 줄에서 breakingArtifact 는 어떤 소비자 픽스처와도 대조되지 않고, 이미 발행된 버전인지도 확인되지 않은 채 이력에 들어간다. 이 클래스의 존재 이유인 두 규칙 — 소비자 컴파일 게이트와 릴리스 버전 불변성 — 을 둘 다 우회한다.

왜 이 형태가 생겼나. 판정과 기록이 두 호출로 나뉘어 있고 그 사이를 묶는 것이 호출자의 규율뿐이다. 이 저장소가 여러 가족에서 반복해 온 check-then-act 형태와 같다. 다만 여기서는 경합이 아니라 인자 짝 맞추기가 깨진 지점이다.

테스트는 안전한 형태만 쓴다 — identicalRepublishIsAllowedpublisher.publish(artifact, publisher.evaluate(artifact, List.of())) 로 한 줄에서 짝을 맞춘다. 그 규율을 코드가 강제하지 않는다.

수정. PublishDecision 이 판정 대상의 schemaVersion·schemaHash 를 들고, publish 가 후보와 대조한다. 또는 evaluate 가 발행 가능한 후보를 감싼 토큰을 돌려주고 publish 가 그 토큰만 받는다 — 짝이 어긋날 수 없는 형태가 된다.

17.5 P3 — sha256: 검사가 길이 15자 이상만 요구한다. 저장소 자신의 테스트가 32자 해시를 통과시킨다

같은 검사가 두 곳에 손으로 복사돼 있다.

// GrpcDescriptorArtifact.requireDigest
if (digest == null || !digest.startsWith("sha256:") || digest.length() < 15) throw ;

// GrpcSchemaBaseline 정규 생성자
if (schemaHash == null || !schemaHash.startsWith("sha256:") || schemaHash.length() < 15) throw ;

"sha256:" 이 7자이므로 뒤에 8자만 있으면 통과한다. sha256 digest 는 hex 64자다.

그리고 이 헐거움이 테스트에 이미 드러나 있다.

assertThat(policy.unchangedFromBaseline("sha256:ffffffffffffffffffffffffffffffff")).isFalse();

32자 — sha256 이 아니다. 여기서는 "다른 해시" 역할이라 결과가 바뀌지 않지만, 형식 검사가 이런 값을 유효한 해시로 받는다는 사실 자체가 이 값 객체의 주장("the hashes that prove which bytes it was built from")을 약하게 만든다.

수정. sha256: 뒤 64자 hex 를 정규식으로 요구하고, 검사를 한 곳에 둔다 — 두 record 가 같은 규칙을 각자 적고 있는 지금 형태에서는 한쪽만 조여도 다른 쪽이 남는다.

확인된 설계(문제 아님)
  • Buf CLI 를 부르지 않고 같은 판정을 계산한 것과 그 근거 — 바이너리가 없을 때 조용히 통과하는 게이트보다 낫다.
  • 파괴적 범주를 소스 파괴 감지 여부로 나눈 것 — 유선 호환만 보면 이름 변경이 호환으로 통과한다.
  • 기준선을 릴리스에 고정한 것 — 커밋 대 커밋 비교가 답하는 질문이 다르다.
  • 생성물 경로를 빌드 디렉터리로 강제한 것.
  • 생성자를 하나로 못박고 버전 출처를 관리 플랫폼으로 제한한 것.
  • 소비자 요구 사항을 손으로 적지 않고 소스에서 유도한 것 — 손으로 적은 목록이 갱신을 멈춘다.
  • 파괴 종류를 셋으로 나눠 보고하는 것 — 하나의 개수로 합치지 않는다.
  • 근사임을 자바독에 명시하고 그 한계의 근거를 ADR 로 지목한 것.
  • 픽스처를 의존이 아니라 테스트 리소스로 커밋한 것 — 픽스처의 build.gradle.kts 가 스키마 산출물을 1.4.0 으로 고정하고, 그 이유("a fixture that floats to the latest version cannot detect a break, because it is always built against the schema it is meant to be testing")를 파일 안에 적어 두었다.
  • 요구 사항이 빈 픽스처를 거부한 것 — 아무것도 요구하지 않는 픽스처는 모든 스키마를 통과시킨다.
  • PublishDecision 정규 생성자가 허용과 차단 사유의 모순을 거부한 것 — 허용인데 차단 사유가 있거나, 거부인데 사유가 없으면 던진다.
  • 테스트가 픽스처를 클래스로더로 실제 파일에서 읽는 것 — 유도 규칙을 리터럴이 아니라 커밋된 텍스트에 대고 돌린다.

Source anchors

src/grpc/grpc-codegen/build.gradle:1-12
main/java/…/codegen/GrpcConsumerFixture.java:1-158
main/java/…/codegen/GrpcSchemaArtifactPublisher.java:1-113
main/java/…/codegen/GrpcCodegenManifest.java:1-81
main/java/…/codegen/GrpcBufPolicy.java:1-75
main/java/…/codegen/GrpcGeneratedPackagePolicy.java:1-68
main/java/…/codegen/GrpcDescriptorArtifact.java:1-59
main/java/…/codegen/GrpcCodegenOutput.java:1-58
main/java/…/codegen/GrpcBreakingCategory.java:1-43
main/java/…/codegen/GrpcSchemaBaseline.java:1-37
test/java/…/codegen/GrpcDescriptorArtifactTest.java:1-248
test/java/…/codegen/GrpcBufPolicyTest.java:1-88
test/java/…/codegen/GrpcCodegenManifestTest.java:1-78
test/resources/consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java:1-33
test/resources/consumer-fixtures/v1/build.gradle.kts:1-17
grpc/grpc-testkit/…/release/GrpcStableReleaseGate.java:3,35-38 (PublishDecision 소비 지점)
grpc/grpc-testkit/build.gradle:55 · grpc/grpc-spring-boot-starter/build.gradle:19 (의존 선언)

A20-GRPC-CORE-API. grpc-core-api

분석 중에는 grpc/GRPC-CORE-API.md 파일이었다. 308줄.

grpc-core-api 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) — src/main production 32파일 1,897줄 + src/test 7파일 926줄 축자 통독 완료. STRUCTURAL_ONLYgradle.lockfile 하나. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-core-api SSOT owner: grpc-core-api integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • allowed_dependencies: [] — 이 저장소에서 의존성이 하나도 없는 두 리프 중 하나(다른 하나는 messaging-core-api)
  • runtime_memberships: [] — build-only
// build.gradle:3-9
// The platform's port layer: identifiers, method policy, execution evidence, failure model,
// deadline primitives and request context.
//
// No dependencies at all, and that is the contract rather than an accident. The Stable plan's
// Global Constraints make `grpc-core-api` framework-free so "evidence and policy do not know about
// a transport" is verifiable instead of aspirational — the same rule `messaging-core-api` holds.
// A type here may not name io.grpc, Spring, Netty, protobuf or a database.
패키지 파일 성격
core 8 390 식별자·상태 코드·RPC 종류·Stable 모듈 목록과 불변식
error 4 276 실패 문맥·범주·완료 결과·플랫폼 예외
context 4 273 요청 문맥·메타데이터 키와 예산·클라이언트 신원
evidence 4 239 전송·업무·스트림 세 축
deadline 4 238 예산·프로파일·취소 토큰·마감 예외
policy 4 295 메서드 정책과 목록, 멱등 프로파일, wait-for-ready
ledger 4 176 연산 원장 포트와 기록·신원·상태

가장 큰 파일 넷: GrpcMethodPolicyCatalog 124 · GrpcMethodPolicy 100 · GrpcFailureContext 99 · GrpcRequestContext·GrpcExecutionEvidence 87.

main 총 32파일 / 1,897줄.

Coverage ledger
scope count disposition reason
main/java/** 32 FULL_READ 1,897줄. 위 표가 전부
test/java/** 7 FULL_READ 926줄
build.gradle 1 FULL_READ 10줄 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일 — 생성물

UNCLASSIFIED 0.

이 표는 2026-09-01 재통독에서 파일 단위로 다시 세었다. 이전 판은 main/java/** 를 "전 파일", test/java/** 를 6(실제 7)으로 적었다. 그 미세한 오차가 §17.4–§17.6 이 표에 없던 이유다. UNCLASSIFIED 0.


1. 증거 세 축

GrpcExecutionEvidence 가 전송·업무·스트림을 함께 들고 절대 합치지 않는다.

"The same type is used by the failure model and by the observation convention. That is deliberate: when the exception and the metric are built from different snapshots of what happened, the incident review has two accounts of one call and no way to choose between them."

관측될 수 없는 조합을 생성자가 거부한다 — 단항이 스트림 증거를 들 수 없고, 보내지 않은 요청이 업무 증거를 들 수 없다.

승격 메서드가 하나뿐인 것도 의도다.

"This is the promotion the plan forbids, written as the one method that is allowed to observe headers — so the forbidden edit is visible as a change to this method rather than as a plausible line somewhere in an interceptor."

즉 응답 헤더를 봤다는 사실이 업무 축을 건드리지 못하게 하고, 그 규칙을 어기려면 이 메서드를 고쳐야 한다.

2. 완료 결과가 상태 코드와 분리된 이유

"a mutation that times out is DEADLINE_EXCEEDED on the wire and COMPLETION_UNKNOWN in the business, and a caller that reads the first as the second's answer either loses a committed write or performs it twice."

forMutation 의 판정 순서가 다섯 단계다.

커밋 확인됨            → COMPLETED
부분 스트림            → PARTIAL_STREAM
상태 OK                → COMPLETED
전송이 미시작을 증명   → REJECTED
그 밖                  → 상태별 표

상태별 표에서 DEADLINE_EXCEEDED·UNAVAILABLE·CANCELLED·UNKNOWN·INTERNAL·ALREADY_EXISTS·ABORTED·DATA_LOSSCOMPLETION_UNKNOWN 이다. ALREADY_EXISTS 가 모호에 있는 것이 특히 정확하다 — 재시도가 그 답을 받으면 첫 시도가 성공했다는 뜻일 수 있다.

3. 메서드 정책 목록

가장 유용한 성질이 빌드를 깨는 쪽이다.

"when a descriptor method set is declared, registering a policy for a method the schema does not have is an error. That catches the rename — the method becomes CreateDocumentV2, the policy still names CreateDocument, and every call to the new method silently runs with default deadline, default retry and no idempotency requirement."

그리고 정책 없는 메서드는 조회에서 던진다 — 정책 없는 호출은 마감도 멱등 프로파일도 없고, 그것을 서비스하려면 둘 다 지어내야 한다.

4. Stable 모듈 목록과 불변식

GrpcStableModuleCatalog 이 Stable 12 와 Advanced 6 을 상수로 든다.

GrpcStableBuildInvariant.advancedDependencyAllowed() 가 인자를 받지 않는 이유가 적혀 있다.

"the answer does not vary by module, by capability or by environment. A method that could return true for some input would be the seam through which 'just this one Advanced type in the starter' arrives."

그리고 누출을 던지지 않고 집합으로 돌려주는 이유도 적혀 있다 — 첫 하나만 보고하는 게이트는 넷을 지우는 일을 네 번의 대화로 만든다.

10. 테스트 레인

여섯 테스트. 증거 조합 거부, 완료 결과 파생, 정책 목록의 서술자 대조와 중복 거부, 마감 예산, 메타데이터 예산, 식별자 경계, 모듈 목록을 확인한다.

12. negative-space probes

12.1 도달성. 이 리프는 가족 전체의 포트 계층이므로 참조가 가장 많다. 다만 §17.3 의 타입은 예외다.

12.2 프레임워크 부재 확인. io.grpc·Spring·Netty·protobuf·JDBC 를 이름으로 부르는 import 가 main 에 없다. build.gradle 의 의존 블록도 비어 있다.

12.3 실제로 쓰이는 게이트. 이 가족의 다른 게이트들과 달리 GrpcStableBuildInvariant.requireNoAdvancedDependency 는 production 호출자가 둘 있다 — grpc-spring-boot-starter 의 시작 검증기와 grpc-advanced-bootstrap 의 모듈 가드. 불변식의 양쪽을 각각 다른 리프가 부른다.

12.4 드리프트. build.gradle 이 서술한 일곱 패키지가 전부 존재하고, 파일 수는 core 8 · policy 4 · evidence 4 · error 4 · deadline 4 · context 4 · ledger 4 = 32 다.

12.5 검증만 되고 강제되지 않는 성분. GrpcMetadataBudget.maxTotalBytes(§17.5). 같은 형태를 grpc-policy 에서도 찾았다 — GrpcContextPropagationPolicy.clearAfterTask(그 리프 §17.8). 두 자리 모두 compact constructor 의 가드가 유일한 소비자다.

16. 확인하지 못한 것

  • 서술자 대조 경로를 실제 스키마로 돌려 보지 않았다(§17.1). 저장소에 컴파일된 서술자가 없다.
  • 상태 코드별 매핑을 실제 서버 응답으로 재현하지 않았다. 표와 근거 문장으로 판정했다.

17. 손볼 것

17.1 P3 — 정책 목록의 가장 강한 성질을 이 저장소에서는 쓸 수 없다

withDescriptorMethods 를 부르는 곳은 이 리프의 테스트 두 줄뿐이다.

grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:145
grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:175

GrpcMethodPolicyCatalog.builder() 를 부르는 곳은 저장소 전체에서 전부 테스트다. 그리고 그중 어느 것도 서술자 집합을 선언하지 않는다(위 두 줄 제외).

자바독이 그 상태를 미리 서술한다 — 서술자가 없으면 "the catalog is materially weaker … there is nothing to compare a policy's method name against."

그리고 서술자가 없는 이유는 옆 리프에 있다. grpc-codegen 이 서술자 산출물을 정의하지만 저장소에 protobuf 플러그인이 없어 protoc 이 돌지 않는다. 즉 이름 변경을 잡는 성질은 코드 생성 레인이 켜지기 전까지 성립할 수 없다.

기록하는 이유는 이것이 이 클래스가 존재하는 첫 번째 이유로 적혀 있기 때문이다. 수정은 코드 생성 레인이 생길 때 그 서술자를 목록 조립에 연결하는 것이고, 그때까지는 자바독이 그 조건을 명시하는 편이 낫다.

17.2 P3 — 모듈 목록 테스트가 레지스트리와 목록을 붙들지 않는다

클래스 javadoc 이 두 SSOT 의 관계를 적는다.

"This repository's module registry (src/config/architecture/modules.json) is the SSOT for which Gradle projects exist; this catalog is the SSOT for which of them the Stable contract covers, and GrpcStableModuleCatalogTest holds the two together."

그 테스트는 레지스트리를 읽지 않는다. 다섯 테스트가 하는 일은 목록을 리터럴과 대조하고, 두 집합의 서로소를 확인하고, 누출 판정을 확인하는 것이다.

assertThat(catalog.modules()).containsExactlyInAnyOrder(리터럴);
assertThat(GrpcStableModuleCatalog.advancedModules()).isNotEmpty().noneMatch(catalog::isStable);

modules.json 을 읽는 줄도, 파일 경로도 없다.

두 목록은 오늘 일치한다 — 레지스트리의 grpc 계열 리프가 18 개이고 목록이 12 + 6 이다. 어긋난 것은 그 일치를 무엇이 지키는가다.

같은 저장소가 이 형태를 messaging 가족에서 이미 기록했다 — 정확한 목록은 레지스트리가 소유하므로 산문에서 세지 않는다, 세는 순간 다시 표류한다.

수정은 테스트가 modules.json 을 읽어 grpc 계열 리프 집합과 두 상수 집합의 합집합을 대조하는 것이다. 그 테스트가 있으면 새 리프가 어느 쪽에도 들어가지 않은 채 추가되는 것을 잡는다.

17.3 P3 — RESOURCE_EXHAUSTED 매핑이 그 상태의 두 출처 중 하나만 가정한다
case INVALID_ARGUMENT, UNAUTHENTICATED, PERMISSION_DENIED, NOT_FOUND,
     FAILED_PRECONDITION, OUT_OF_RANGE, UNIMPLEMENTED, RESOURCE_EXHAUSTED -> REJECTED;

이 분기는 전송이 미시작을 증명하지 못한 뒤에 도달한다. 즉 "보냈는지 모르지만 이 상태 코드는 거절을 뜻한다" 는 판정이다.

목록의 나머지 일곱은 서버가 일을 시작하기 전에 답하는 상태다. RESOURCE_EXHAUSTED 는 두 출처를 갖는다.

  • 이 플랫폼 자신의 승인 제어기가 부하를 흘려보낼 때 — 일을 쓰기 전이므로 거절이 맞다.
  • 원격 서버가 작업 중 자원(할당량·디스크)을 소진했을 때 — 부분 커밋이 있을 수 있다.

이 클래스의 원칙은 보수적이다. 자바독이 두 기본값(DEADLINE_EXCEEDED·UNAVAILABLE 를 모호로)을 계획의 전역 제약이라 부르고, 그 이유는 "보냈는지 모르면 모호" 다. RESOURCE_EXHAUSTED 는 그 원칙에서 벗어난 유일한 항목이다.

ABORTED 가 모호에 있는 것과 대비된다 — 트랜잭션 충돌은 서버가 일을 시작한 뒤의 상태이고, 그래서 모호다.

수정은 둘 중 하나다. RESOURCE_EXHAUSTED 를 모호로 옮기거나, 그 상태를 이 플랫폼이 발행한 것과 원격이 발행한 것으로 구분해 전자만 거절로 두는 것이다. 후자는 증거 축에 발신자 정보를 요구하므로 전자가 현실적이다.

17.4 P3 — 하나의 상태 코드가 같은 메서드 안에서 두 답을 갖는다

forMutation 은 스위치에 닿기 전에 OK 를 먼저 처리한다.

if (statusCode == GrpcStatusCode.OK) { return COMPLETED; }
if (evidence.transport().provesNotStarted()) { return REJECTED; }
return switch (statusCode) {
  
  case OK, ALREADY_EXISTS, ABORTED, DATA_LOSS -> COMPLETION_UNKNOWN;   // ← OK 가 여기에도 있다
};

스위치의 OK 분기는 도달하지 않는다. 열거형 전수 처리를 컴파일러가 요구하므로 항목 자체는 필요하지만, 그 값이 위의 가드와 반대다.

결과는 잠재적 함정이다. 누군가 위의 OK 가드를 "중복이니까" 지우면 컴파일은 통과하고 OK 인 변경이 COMPLETION_UNKNOWN 이 된다 — 성공한 변경마다 대사(reconciliation)를 요구하게 된다. 이 리프의 다른 자리들은 그런 편집이 눈에 띄도록 설계되어 있다(예: 승격 메서드를 하나로 좁힌 것).

수정은 한 글자다. 스위치의 OKCOMPLETED 로 옮기면 두 자리의 답이 같아지고, 가드가 사라져도 결과가 바뀌지 않는다.

17.5 P3 — 메타데이터 예산의 두 성분 중 하나는 강제되지 않고, 나머지 하나는 바이트가 아니라 문자를 센다

GrpcMetadataBudget 은 세 성분을 갖는다 — maxTotalBytes·maxUserDefinedBytes·maxEntries.

check(...) 가 보는 것은 뒤의 둘뿐이다.

if (metadata.size() > maxEntries) { throw ; }
int userDefinedBytes = 0;
for () { userDefinedBytes += entry.getKey().name().length() + value.length(); }
if (userDefinedBytes > maxUserDefinedBytes) { throw ; }
// maxTotalBytes 는 여기서 쓰이지 않는다

첫째, maxTotalBytes 는 읽히지 않는다. 저장소 전체에서 이 접근자를 부르는 곳은 compact constructor 의 순서 가드와 테스트 단언 하나뿐이다. 자바독은 그 이유를 설명한다 — 하드 총계를 넘기는 것은 프레임워크가 던지는 전송 거절이고, 여기서 함께 검사하면 "고칠 수 있는 쪽" 과 "고칠 수 없는 쪽" 이 한 자리에서 발견된다는 것. 판단은 옳다. 다만 그 결과로 이 record 는 자기가 쓰지 않는 수를 성분으로 들고 있고, 이름은 그것이 강제된다고 읽힌다.

둘째, 단위가 어긋난다. 성분 이름은 ...Bytes 인데 세는 것은 String.length(), 즉 UTF-16 코드 단위다. 키는 [a-z0-9._-] 로 제한되어 ASCII 지만 값에는 문자 집합 제약이 없다. 다중 바이트 문자를 담은 값은 실제 프레임보다 적게 계산된다.

gRPC 의 ASCII 메타데이터 값은 프로토콜 상 인쇄 가능 ASCII 여야 하므로 실무에서는 대개 일치한다. 다만 그 제약을 이 클래스가 검사하지 않으므로, 일치는 보장이 아니라 관행이다.

수정은 둘 다 작다 — value.getBytes(StandardCharsets.US_ASCII).length 로 세거나 값의 문자 집합을 GrpcMetadataKey.Kind.ASCII 에 맞춰 검증하고, maxTotalBytes 는 성분에서 빼고 javadoc 의 서술로 남긴다.

17.6 P3 — 직렬화 가능하다고 선언한 예외가 자기 내용을 직렬화하지 않는다
public class GrpcPlatformException extends RuntimeException {
  private static final long serialVersionUID = 1L;
  private final transient GrpcFailureContext context;   // ← transient
  
  public boolean requiresReconciliation() { return context.completionOutcome().requiresReconciliation(); }
}

serialVersionUID 는 이 타입이 직렬화된다는 선언이고, transient 는 유일한 필드가 그 직렬화에서 빠진다는 선언이다. 둘이 함께 있으면 역직렬화된 예외는 context == null 이고, 공개 메서드 둘 중 하나(requiresReconciliation())가 NPE 를 던진다.

transient 자체는 강제된 선택이다 — GrpcFailureContextSerializable 을 구현하지 않으므로 필드를 남기면 예외가 직렬화되지 않는다.

기록하는 이유는 이 리프의 서술 규율과 대비되기 때문이다. 다른 자리에서는 부재마다 이유가 붙어 있다("There is no factory that takes raw metadata, and that absence is the design"). 여기에는 transient 의 이유도, 역직렬화 뒤의 계약도 적혀 있지 않다.

도달성은 낮다. gRPC 예외가 자바 직렬화를 지나는 경로는 이 저장소에 없다. 수정은 셋 중 하나다 — GrpcFailureContext 와 그 구성 요소를 Serializable 로 만들거나, serialVersionUID 를 지워 직렬화를 지원하지 않음을 명시하거나, context()requiresReconciliation() 이 null 문맥을 다루도록 하고 그 이유를 적는 것.

확인된 설계(문제 아님)
  • 의존성 0 을 계약으로 삼고 그 이유를 적은 것 — "evidence and policy do not know about a transport" 가 검증 가능해진다.
  • 증거 세 축을 한 타입에 두고 관측 불가 조합을 생성자가 거부한 것.
  • 승격 메서드를 하나로 좁혀 금지된 편집이 그 메서드의 변경으로 보이게 한 것.
  • 완료 결과를 상태 코드와 분리한 것과 그 예시.
  • ALREADY_EXISTS·ABORTED 를 모호로 둔 것.
  • 정책 없는 메서드를 조회에서 던지는 것.
  • 서술자 대조를 선택 사항으로 두되 그 부재의 대가를 자바독에 적은 것.
  • advancedDependencyAllowed() 가 인자를 받지 않는 것과 그 근거.
  • 누출을 집합으로 돌려주는 것.

Source anchors

src/grpc/grpc-core-api/build.gradle:1-10
main/java/…/policy/GrpcMethodPolicyCatalog.java:1-124   (§17.1 withDescriptorMethods:80-87)
main/java/…/policy/GrpcMethodPolicy.java:1-100
main/java/…/error/GrpcFailureContext.java:1-99
main/java/…/context/GrpcRequestContext.java:1-87
main/java/…/evidence/GrpcExecutionEvidence.java:1-87
main/java/…/deadline/GrpcDeadlineBudget.java:1-85
main/java/…/evidence/GrpcStreamEvidence.java:1-80
main/java/…/core/GrpcStableModuleCatalog.java:1-79      (§17.2)
main/java/…/error/GrpcCompletionOutcome.java:1-69       (§17.3 · §17.4 forMutation:360-390)
main/java/…/deadline/GrpcCancellationToken.java:1-68
main/java/…/context/GrpcMetadataBudget.java:1-66        (§17.5 check:130-154)
main/java/…/context/GrpcMetadataKey.java:1-65
main/java/…/error/GrpcFailureCategory.java:1-64
main/java/…/deadline/GrpcDeadlineProfile.java:1-59
main/java/…/ledger/GrpcOperationLedgerRecord.java:1-59
main/java/…/context/GrpcClientIdentity.java:1-55
main/java/…/core/GrpcMethodName.java:1-55
main/java/…/core/{GrpcStableBuildInvariant:1-53, RpcType:1-53, GrpcStatusCode:1-52,
                  GrpcIdentifiers:1-47, GrpcServiceName:1-37, GrpcChannelProfileName:1-24}
main/java/…/policy/{RpcIdempotencyProfile:1-49, WaitForReadyPolicy:1-22}
main/java/…/ledger/{GrpcOperationLedger:1-50, GrpcOperationIdentity:1-39, GrpcOperationLedgerState:1-28}
main/java/…/error/GrpcPlatformException.java:1-44       (§17.6)
main/java/…/evidence/{GrpcTransportEvidence:1-41, GrpcBusinessEvidence:1-31}
main/java/…/deadline/GrpcDeadlineExceededException.java:1-26
test/java/…/ 7파일 926줄 (GrpcMethodPolicyCatalogTest:183 · GrpcFailureContextTest:182 ·
             GrpcMetadataBudgetTest:174 · GrpcDeadlineBudgetTest:124 · GrpcExecutionEvidenceTest:120 ·
             GrpcCoreIdentifiersTest:83 · GrpcStableModuleCatalogTest:60)
src/config/architecture/modules.json (§17.2 — 테스트가 읽지 않는 SSOT)
grpc-spring-boot-starter/…/GrpcPlatformStartupValidator.java:176 (GrpcStableBuildInvariant 실사용)
grpc-advanced/grpc-advanced-bootstrap/…/GrpcAdvancedModuleGuard.java:76 (같은 불변식의 반대편)

A20-GRPC-DISCOVERY. grpc-discovery

분석 중에는 grpc/GRPC-DISCOVERY.md 파일이었다. 239줄.

grpc-discovery 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 7파일 409줄, test 2파일 220줄 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-discovery SSOT owner: grpc-discovery integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • allowed_dependencies: ["grpc-core-api", "grpc-client"]
  • runtime_memberships: [] — build-only
파일 LOC
GrpcKubernetesProfile 90
GrpcDiscoveryPolicyValidator 67
GrpcResolverProfile 63
GrpcKubernetesProfileValidator 61
GrpcResolverType · GrpcKubernetesRoutingMode 45 · 45
GrpcStableLoadBalancer 38
main 합계 (7파일) 409
GrpcKubernetesProfileTest · GrpcDiscoveryPolicyValidatorTest 127 · 93
Coverage ledger
scope count disposition reason
main/java/** 7 FULL_READ 409줄 전 본문
test/java/** 2 FULL_READ 220줄 전 본문 · 테스트 15개
build.gradle 1 FULL_READ 9줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-5
// Stable discovery: Static/DNS resolvers, pick_first/round_robin load balancing, and the
// Kubernetes VIP / headless / mesh routing profiles. Custom resolvers, custom load balancers and
// xDS are Advanced and are refused here by GrpcDiscoveryPolicyValidator.

2. 이 리프가 붙드는 한 가지 짝

세 타입이 같은 사실을 다른 각도에서 말한다.

  • GrpcResolverType — 각 리졸버가 주소를 여럿 돌려줄 수 있는가. STATIC·DNS 는 예, UNIX 는 아니오.
  • GrpcStableLoadBalancer — 주소 수에 맞는 정책. 1개면 PICK_FIRST, 여럿이면 ROUND_ROBIN.
  • GrpcKubernetesRoutingMode — 누가 균형을 잡는가. VIP 는 kube-proxy, headless 는 클라이언트, MESH 는 사이드카.

세 javadoc 이 같은 실패를 다르게 서술한다.

GrpcResolverType — "A resolver that returns one address makes round_robin a no-op, and the pairing is the most common way a deployment has load balancing on paper and none in practice."

GrpcStableLoadBalancer — "pick_first over a headless record pins every request from this client to one pod, which shows up as one instance at capacity while the rest are idle."

GrpcKubernetesRoutingMode — "A Service VIP balances per connection in kube-proxy, which for a long-lived HTTP/2 connection means it does not balance at all after the first request."

3. 두 검증기가 다른 질문에 답한다

GrpcKubernetesProfileValidator javadoc 이 분리 이유를 적는다.

"The resolver validator asks whether a load-balancing policy does anything over the addresses it will see; this one asks whether the deployment shape, the retry owner and the stream obligations agree with each other. A deployment can have a perfectly coherent resolver profile and still have put retries in two places."

검사 어디
균형 정책이 주소 수에 대해 무의미한가 GrpcDiscoveryPolicyValidator
재시도 소유자가 라우팅 모드가 요구하는 것과 다른가 GrpcKubernetesProfileValidator
VIP 인데 긴 스트림을 싣는가
배수 유예가 재접속 예산보다 짧은가

4. 생성자가 거부하는 것과 검증기가 보고하는 것

GrpcResolverProfile 정규 생성자가 네 조합을 아예 만들 수 없게 한다 — 주소 0 이하, 단일 엔드포인트 리졸버에 복수 주소, 음수 갱신 주기, DNS 인데 갱신 주기 0.

GrpcKubernetesProfile 정규 생성자는 셋을 막는다 — 메시 라우팅에 in-process 재시도 소유자, 긴 스트림인데 재접속 예산 0, 긴 스트림인데 배수 유예 0.

두 겹의 역할 분담이 이 저장소의 다른 곳에 적힌 규칙과 같다 — 위험한 조합은 정책이 아니라 생성자가 거부하게 만든다.

그리고 그 분담 때문에 검증기의 재시도 소유자 규칙은 일부 조합에서만 발화한다. MESH + GRPC_PLATFORM 은 생성자가 먼저 던지므로(둘 다 in-process 재시도) 검증기까지 오지 않고, MESH + NONE 이나 K8S_VIP + SERVICE_MESH 는 생성자를 통과해 검증기가 잡는다. 도달 불가 분기가 아니라 역할 분담이다.

10. 테스트 레인

두 테스트 220줄 · 15개.

GrpcDiscoveryPolicyValidatorTest 7개 — Stable 리졸버 셋의 열거, Advanced 스킴과 미지 스킴 거부, 주소 수에 따른 권고, 단일 주소 위의 round_robin 보고, 올바른 짝의 통과, 갱신 주기 0 인 DNS 거부, 단일 엔드포인트 리졸버의 복수 주소 거부.

GrpcKubernetesProfileTest 8개 — 라우팅 모드별 균형자·재시도 소유자, 메시 + in-process 재시도의 생성자 거부, 긴 스트림의 두 필수 값, 세 팩토리의 통과, headless 인데 주소 1개, VIP 인데 긴 스트림, 배수 유예 < 재접속 예산, 라우팅 모드가 함의하는 리졸버 프로파일.

레인에 없는 것 하나. §4 가 "생성자를 통과해 검증기가 잡는다" 고 설명한 분기 — retryOwner != routingMode.requiredRetryOwner() — 를 실제로 발화시키는 테스트가 없다.

// GrpcKubernetesProfileValidator:204
if (profile.retryOwner() != profile.routingMode().requiredRetryOwner()) { violations.add(); }

routingModesImplyTheirOwners 는 열거형의 requiredRetryOwner() 값만 단언하고 검증기를 부르지 않는다. aMeshProfileMayNotAlsoRetryInProcess 는 생성자 쪽을 친다. MESH + NONE 이나 K8S_VIP + SERVICE_MESH — 두 검증기 분담을 실증하는 조합 — 은 어느 테스트에도 없다. 규칙은 있고 그것을 붙드는 단언이 없다.

12. negative-space probes

12.1 도달성. 이 리프 밖의 production 소비자는 하나뿐이다.

타입 leaf 밖 main 참조
GrpcDiscoveryPolicyValidator 1 — GrpcPlatformStartupValidator.validateChannels
나머지 6종 0

그리고 그 하나의 소비자인 시작 검증기는 시작 시 실행되지 않는다(grpc-spring-boot-starter §17.1). 그러므로 Advanced 스킴 거부(requireStableScheme)에 도달하는 production 경로가 없다.

12.2 거절 목록은 안전이 아니라 메시지를 위해 있다.

private static final List<String> ADVANCED_SCHEMES = List.of("xds", "consul", "etcd", "eureka");

if (ADVANCED_SCHEMES.contains(scheme)) { throw new IllegalArgumentException("… Advanced capability …"); }
return GrpcResolverType.forScheme(scheme).orElseThrow(() -> new IllegalArgumentException("unknown resolver scheme …"));

두 번째 줄이 이미 허용 목록이다 — GrpcResolverType 이 아는 것은 static·dns·unix 셋뿐이고, 그 밖은 전부 orElseThrow 로 떨어진다. 그러므로 xds 는 거절 목록이 없어도 거부된다.

거절 목록이 하는 일은 거부 사유를 바꾸는 것이다 — "unknown resolver scheme" 대신 "Advanced capability with its own control plane and promotion gate". 클래스 javadoc 이 그 구분을 명시한다.

"xds:/// in a Stable profile is not a configuration mistake to warn about — it is a capability with its own control plane, its own failure modes and its own promotion gate."

읽는 사람에게 중요한 함의: 다섯 번째 Advanced 스킴 이름을 이 목록에 넣지 않아도 안전은 유지된다. 빠지면 나빠지는 것은 메시지의 정확도뿐이고, 그것이 이 목록이 감당하는 유일한 부채다. 기본 거절이 바깥을 지킨다.

12.4 드리프트. build.gradle 이 서술한 범위(Static/DNS, pick_first/round_robin, VIP/headless/mesh, xDS 거부)가 전부 코드에 있다. 드리프트 없음.

16. 확인하지 못한 것

  • 실제 DNS 리졸버로 헤드리스 레코드를 조회해 주소 수를 확인하지 않았다. 이 리프는 그 수를 입력으로 받는다.
  • 시작 검증기를 통한 스킴 거부를 실행으로 확인하지 않았다. 그 검증기가 돌지 않는다.
  • 테스트를 실행하지 않았다. 15개 전부 본문으로만 확인했다.
  • §17.3 의 violations(profile, 0) 을 실행으로 재현하지 않았다. resolverProfileGrpcResolverProfile 정규 생성자 경로로 판정했다.

17. 손볼 것

17.1 P3 — 프로파일이 스트림 재접속 예산을 선언하는데 그것이 함의하는 DNS 갱신 주기를 정하지 않는다

GrpcKubernetesProfile 은 세 시간 값을 다룬다.

streamReconnectBudget   // 프로파일이 선언
readinessDrainGrace     // 프로파일이 선언
refreshInterval         // resolverProfile(...) 이 30초로 하드코딩
public GrpcResolverProfile resolverProfile(int expectedAddressCount) {
  return new GrpcResolverProfile(
      GrpcResolverType.DNS, routingMode.loadBalancingPolicy(), Duration.ofSeconds(30), expectedAddressCount);
}

검증기는 앞의 둘만 비교한다 — 배수 유예가 재접속 예산보다 짧으면 위반. 셋째는 비교 대상에 없다.

그래서 headlessStreaming()(재접속 예산 5초, 배수 유예 30초)에서 갱신 주기는 여전히 30초다. 롤아웃으로 스트림이 끊긴 클라이언트가 5초 예산 안에 재접속하려 할 때, 그 클라이언트의 DNS 캐시는 최대 30초 동안 사라진 파드 주소를 들고 있을 수 있다.

그 실패가 GrpcResolverProfile 자신의 javadoc 이 서술한 것이다 — "A channel that resolved once at startup keeps sending to addresses that stopped existing an hour ago; the calls fail with UNAVAILABLE and the deployment looks unhealthy long after it finished."

수정은 갱신 주기를 재접속 예산에서 파생시키거나(예: 예산 이하), 검증기에 세 값의 순서 규칙을 추가하는 것이다.

17.2 P3 — 리졸버 검증기의 규칙이 하나뿐인데 javadoc 은 복수형으로 서술한다
public static List<String> violations(GrpcResolverProfile profile) {  }   // 규칙 1개

javadoc 은 "Checks a discovery configuration for the things that look right and are not" 라고 적는다. 실제로 담긴 규칙은 균형 정책의 무의미함 하나다.

나머지 위험 조합은 GrpcResolverProfile 정규 생성자가 이미 거부하므로 결과적으로 빈틈은 아니다. 다만 목록으로 보고하는 API 형태와 규칙 하나라는 내용이 어긋나 있어, 다음 사람이 여기에 규칙을 더할 자리로 읽거나 이미 여러 규칙이 있다고 읽는다. §17.1 이 실제로 그 자리다.

17.3 P3 — 목록으로 보고하는 검증기가 주소 수 0 에서 던진다
public static List<String> violations(GrpcKubernetesProfile profile, int expectedAddressCount) {
  
  List<String> violations = new ArrayList<>(
      GrpcDiscoveryPolicyValidator.violations(profile.resolverProfile(expectedAddressCount)));

profile.resolverProfile(n)new GrpcResolverProfile(DNS, …, n) 을 만들고, 그 정규 생성자가 거부한다.

if (expectedAddressCount < 1) {
  throw new IllegalArgumentException("a target resolves to at least one address");
}

그래서 violations(profile, 0) 은 빈 목록도 위반 목록도 아닌 IllegalArgumentException 이다. 같은 메서드가 profile == null 에는 명시적으로 던지고 나머지는 목록으로 답하므로, 호출자는 이 API 를 "던지지 않고 보고한다" 로 읽는다.

왜 0 이 실제 값인가. expectedAddressCount 는 이 리프가 계산하지 않고 입력으로 받는 값이고(§16), 그 출처는 헤드리스 레코드의 DNS 조회 결과다. 롤아웃 중 파드가 모두 교체되는 순간이나 셀렉터가 어긋난 서비스에서 그 답은 0 이다. 그것은 이 리프가 다루는 문제 영역 안의 상태이지 프로그래밍 오류가 아니다 — 그리고 운영자가 가장 보고받고 싶어 할 상태다.

GrpcResolverProfile 쪽 거부 자체는 옳다. 값 객체가 "주소 0 개인 목표"를 표현하지 않는 것은 §4 의 두 겹 분담과 일치한다. 어긋난 것은 그 위에 얹힌 검증기가 그 예외를 그대로 통과시킨다는 점이다.

수정. violationsexpectedAddressCount < 1 을 먼저 보고 위반 문자열로 보고한 뒤 나머지 검사를 건너뛴다. 그러면 이 리프가 답할 수 있는 가장 중요한 배포 상태 하나가 예외가 아니라 목록의 한 줄이 된다.

확인된 설계(문제 아님)
  • 리졸버의 다중 주소 가능성을 열거형 속성으로 둔 것 — 짝이 맞지 않는 조합을 타입 수준에서 판정할 수 있다.
  • 위험한 조합을 생성자가 거부하고 애매한 조합만 검증기가 보고하는 두 겹.
  • 두 검증기를 분리하고 그 이유를 적은 것.
  • Advanced 스킴을 이름으로 거부하고 그 근거를 적은 것 — 통제 평면과 승격 게이트가 따로 있는 능력이다.
  • 알 수 없는 스킴을 기본 거절로 둔 것.
  • 긴 스트림을 싣는 프로파일에 재접속 예산과 배수 유예를 필수로 만든 것.
  • 세 팩토리(virtualIp · headlessStreaming · mesh)가 각자 일관된 조합을 들고 있고, 테스트가 셋 다 위반 0 임을 확인하는 것 — 기본으로 고르는 값이 스스로의 규칙을 만족한다.
  • GrpcRetryOwner.NONE 이 "아직 정하지 않았다" 와 구분되는 것 — 그 열거형 javadoc 이 "A method whose owner is NONE has been looked at" 라고 적고, 이 리프의 두 겹 분담이 그 값 덕분에 의미를 갖는다(§4).

Source anchors

src/grpc/grpc-discovery/build.gradle:1-9
main/java/…/discovery/GrpcKubernetesProfile.java:1-90
main/java/…/discovery/GrpcDiscoveryPolicyValidator.java:1-67
main/java/…/discovery/GrpcResolverProfile.java:1-63
main/java/…/discovery/GrpcKubernetesProfileValidator.java:1-61
main/java/…/discovery/GrpcResolverType.java:1-45
main/java/…/discovery/GrpcKubernetesRoutingMode.java:1-45
main/java/…/discovery/GrpcStableLoadBalancer.java:1-38
test/java/…/discovery/GrpcKubernetesProfileTest.java:1-127
test/java/…/discovery/GrpcDiscoveryPolicyValidatorTest.java:1-93
src/grpc/grpc-policy/…/resilience/GrpcRetryOwner.java:14-33

A20-GRPC-OBSERVABILITY. grpc-observability

분석 중에는 grpc/GRPC-OBSERVABILITY.md 파일이었다. 277줄.

grpc-observability 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 4파일 354줄, test 1파일 172줄 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-observability SSOT owner: grpc-observability integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • allowed_dependencies: ["grpc-core-api"]
  • runtime_memberships: [] (EVD-325)
항목
production Java 파일 4 (354 LOC)
test Java 파일 1 (172 LOC)
build 파일 build.gradle 12줄
test 메서드(실행 확인) 10 (EVD-325)
선언된 의존 project 1 + vendor 1 (micrometer-core)

파일별 LOC:

파일 LOC 성격
GrpcMetricCardinalityPolicy 123 태그 허용/거절 판정 (static 유틸)
GrpcObservationConvention 99 Micrometer 등록 (유일한 상태 보유 클래스)
GrpcRpcObservation 78 논리 RPC 관측 record
GrpcStreamObservation 54 스트림 수명 관측 record
Coverage ledger
scope count disposition reason
build.gradle 1 FULL_READ 12줄 전문
main/…/observability/*.java 4 FULL_READ 4파일 전 본문 축자 확인 (cycle 2)
test/…/GrpcMetricCardinalityPolicyTest.java 1 FULL_READ 172줄, 10개 @Test 전부 단언 대상 확인

STRUCTURAL_ONLY 0 · UNCLASSIFIED 0.


1. 모듈의 정체와 경계

// build.gradle:3-5
// Bounded observability: logical RPC vs physical attempt vs stream lifecycle, with a cardinality
// policy that refuses payload, raw metadata and any actor/tenant/object/stream/idempotency
// identifier as a tag.

세 층위를 구별한다 — 논리 RPC, 물리 시도, 스트림 수명주기.

Micrometer 를 api 로 노출하는 이유도 build.gradle 에 적혀 있다 — "the observation convention's public signatures name Micrometer types, so wiring it requires naming them." 실제로 GrpcObservationConvention 의 생성자와 boundedTags 반환형이 Micrometer 타입(MeterRegistry, Tags)이므로 그 서술은 코드와 일치한다.

2. 의존성과 런타임 배선

grpc-core-api 에서 쓰는 타입은 넷이다 — GrpcMethodName, GrpcStatusCode, RpcType, GrpcCompletionOutcome. 네 타입 모두 GrpcRpcObservation 의 record 성분이다. GrpcStreamObservationGrpcMethodName 하나만 쓴다.

배선 없음(EVD-325). runtime_memberships 가 비어 있고, 저장소 어디에서도 new GrpcObservationConvention(...) 을 만드는 production 코드가 없다.

3. 컴포넌트 지도

  GrpcMetricCardinalityPolicy   태그 키 allowlist 8 · 명시적 거절 11 · 값 패턴 1
  GrpcObservationConvention     meter 이름 7개 상수 · record 오버로드 2개
  GrpcRpcObservation            9성분 record · tags() 7태그
  GrpcStreamObservation         7성분 record · tags() 5태그

4. 계약·불변식

4.1 allowlist 가 기본 거절이고 거절 목록은 메시지를 위한 것이다

violations(Map) 의 판정 순서가 셋이다.

if (FORBIDDEN_TAGS.contains(key))       "its value space grows with traffic…"
if (!ALLOWED_TAGS.contains(key))        "not on the bounded allowlist [...]"
if (UNBOUNDED_VALUE.matcher(value))     "looks like an identifier or a credential"

클래스 javadoc 이 두 목록이 겹치는 이유를 적는다 — "Everything unlisted is refused anyway; naming the dangerous ones gives the refusal a message that says why rather than just that." 즉 FORBIDDEN_TAGS 는 판정을 바꾸지 않고 진단만 바꾼다. 두 번째 분기가 이미 그것들을 거절한다.

허용 태그 8개: grpc.service · grpc.method · grpc.rpc_type · grpc.status · grpc.channel_profile · grpc.completion_outcome · grpc.retry_bucket · grpc.stream_termination_reason.

명시적 거절 11개: actor_id · tenant_id · object_id · stream_id · idempotency_key · request · response · metadata · authorization · error_detail · trace_id.

4.2 값 검사는 세 형태만 잡는다
Pattern.compile("(?i).*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|sha256:|bearer ).*")

UUID · sha256: 접두 · bearer 접두. 숫자 id, 이메일, 호스트명은 잡히지 않는다. 그리고 . 은 기본적으로 개행에 맞지 않으므로 값에 개행이 섞이면 matches() 가 거짓이 된다.

4.3 재시도는 값이 아니라 버킷이다

retryBucket(int) 이 1-based 시도 수를 받아 0/1/2/3+ 로 접는다. 0 이하는 던진다. javadoc 이 이유를 적는다 — "an attempt count is unbounded in principle and the distinction anyone acts on is first attempt, one retry, several."

4.4 논리 호출과 물리 시도의 분리

GrpcRpcObservation javadoc:

"A retried call is one observation with a retry bucket, and three attempt events beneath it; recording three separate calls instead makes the success rate read as 33% when the caller in fact got its answer."

그 분리가 GrpcObservationConvention.record(GrpcRpcObservation) 에서 실제로 그렇게 구현되어 있다 — RPC_DURATION 타이머는 1회, RPC_ATTEMPTS 카운터는 attempts 만큼 증가. 같은 태그 집합을 쓴다.

4.5 조건부 기록 둘
if (observation.completionOutcome().requiresReconciliation())  COMPLETION_UNKNOWN 카운터
if (!observation.queueWaitTime().isZero())                     QUEUE_WAIT 타이머

대기 시간이 0 이면 타이머를 등록조차 하지 않는다. 즉 큐 대기가 없던 배포에서는 그 meter 가 생기지 않는다.

4.6 생성자 검증의 비대칭 — 의도된 쪽

GrpcRpcObservation 의 검증에서 durationqueueWaitTime 은 음수를 거부하고 deadlineRemaining 은 존재만 요구한다. 그리고 unusedDeadline() 이 음수일 때 빈 값을 돌려준다. 마감을 넘긴 호출을 표현하기 위한 것으로 읽히고, 두 메서드가 그 해석과 일관된다.

4.7 스트림은 지속 시간이 아니라 무엇이 움직였는지로 잰다

GrpcStreamObservation javadoc:

"Duration percentiles are meaningless here — a healthy subscription lasts an hour and an unhealthy one lasts an hour — so what is recorded instead is what actually distinguishes them: how many messages moved, how often the writer stalled waiting for the transport, and how it ended."

그리고 tags() 주석이 "The stream id is deliberately absent" 라고 적는다. 실제로 grpc.stream_idFORBIDDEN_TAGS 에도 있어 두 겹으로 막힌다.

10. 테스트 레인

10 tests, 0 failures, 0 skipped. 전부 GrpcMetricCardinalityPolicyTest(172줄).

테스트 붙드는 것
onlyBoundedTagsAreAllowed allowlist 원소
unboundedIdentifierTagsAreRefused 식별자 5종 거절 + 메시지 문구
contentBearingTagsAreRefused 페이로드·메타데이터·오류 상세 3종
anIdentifierShapedValueIsRefused 허용 키 + UUID/Bearer
unlistedTagsAreRefused 목록 밖 키 + 메시지에 allowlist
attemptsAreBucketed 1→0, 2→1, 4→3+, 99→3+, 0→예외
aRetriedCallIsOneObservation 타이머 1 · 시도 카운터 3
completionUnknownIsCountedSeparately 전용 카운터
streamsAreMeasuredByMessagesAndStalls grpc.stream_id 부재 · 메시지·스톨 카운터
anUnboundedTagThrowsRatherThanBeingDropped 등록 거부가 던지기

12. negative-space probes

12.1 도달성. 블록 전체가 배선되지 않았다(EVD-325). unusedDeadline()·retried()·consumerFellBehind()·allowedTags()·forbiddenTags()·retryBuckets() 의 production 호출자 0.

리프 밖 참조도 0 이다.

$ grep -rn "grpc.observability" --include=*.java src/ | grep -v /grpc-observability/
grpc-core-api/…/GrpcStableModuleCatalog.java:30:   "grpc-observability",     ← 목록 안의 문자열

그런데 두 모듈이 이 리프를 api 로 노출한다.

grpc/grpc-testkit/build.gradle:53           api project(':grpc:grpc-observability')
grpc/grpc-spring-boot-starter/build.gradle:16   api project(':grpc:grpc-observability')

api 는 그 모듈을 쓰는 쪽까지 Micrometer 를 포함한 이 리프의 타입을 물려받는다는 선언인데, 두 모듈 어느 자바 파일도 dev.caskeleton.grpc.observability 를 import 하지 않는다. 스타터 쪽은 같은 형태의 미사용 의존을 셋 더 들고 있다(grpc-spring-boot-starter §12.3).

이 리프의 build.gradle 은 Micrometer 를 api 로 두는 이유를 적어 두었다 — 공개 서명이 Micrometer 타입을 이름으로 부르므로 배선하려면 그것을 명명해야 한다. 그 논거는 이 리프를 실제로 쓰는 모듈에 대해 성립한다. 지금은 쓰지 않는 두 모듈이 그 전파를 받고 있다.

12.2 대조군 — 세 개의 카디널리티/노출 정책.

위치 막는 것 배선
messaging CardinalityGuard 지표 태그 폭발 없음 (EVD-316)
grpc-observability GrpcMetricCardinalityPolicy 태그 키 allowlist + 값 형태 없음
grpc-policy GrpcErrorExposurePolicy 클라이언트에 보낼 수 없는 문자열 블록 미배선

12.3 중복 장치. GrpcStreamTerminationReasongrpc-policy 에 열거형으로 존재한다. 이 리프의 GrpcStreamObservation.terminationReasonString 이다. §17.2 참조.

12.4 문서 드리프트. build.gradle 주석이 거절 대상으로 든 다섯(actor·tenant·object·stream·idempotency)이 FORBIDDEN_TAGS 에 전부 있다. 드리프트 없음.

16. 확인하지 못한 것

  • 이 리프를 실제 MeterRegistry 에 배선해 돌린 적이 없다. 배선 자체가 없으므로 런타임 관측이 불가능하다.
  • UNBOUNDED_VALUE 를 우회하는 값 형태(숫자 id·이메일 등)를 실행으로 확인하지 않았다. 정규식 형태로 판정했다.
  • §17.1-b 의 "마감 잔량에 해당하는 meter 가 없다" 는 meter 이름 상수 일곱 개와 두 record 오버로드 본문으로 판정했다. 다른 이름의 상수가 그 역할을 겸하는지는 이름만 보고 배제했다.
  • 두 모듈의 api 의존이 미사용이라는 것(§12.1)은 패키지 이름 grep 으로 판정했다.

17. 손볼 것

17.1 P3 — queueHighWatermark 는 요구되고 검증되지만 아무도 읽지 않는다

GrpcStreamObservation 의 7성분 중 queueHighWatermark 만 소비자가 없다.

GrpcStreamObservation.java:23   long queueHighWatermark,          ← 선언
GrpcStreamObservation.java:31   … || queueHighWatermark < 0       ← 검증
그 외 저장소 전체            매치 0

tags() 에 없고, GrpcObservationConvention.record(GrpcStreamObservation) 이 등록하는 세 meter(STREAM_LIFETIME·STREAM_MESSAGES·STREAM_FLOW_CONTROL_STALLS) 어디에도 들어가지 않는다. 테스트도 250L 을 넘기고 그 값에 대해 아무것도 단언하지 않는다.

클래스 javadoc 이 "what is recorded instead is …" 로 세 가지를 열거하는데 그 목록에도 없다. 즉 서술과 구현은 일치하고, 어긋난 것은 필수 생성자 인자라는 점이다. 호출자는 측정해서 넘겨야 하고 그 값은 버려진다.

수정은 둘 중 하나다 — STREAM_QUEUE_HIGH_WATERMARK gauge/counter 를 추가하거나, 성분에서 뺀다. 큐 최고 수위는 소비자 지연의 직접 지표이므로 전자가 이 클래스의 목적에 맞는다.

17.1-b P3 — deadlineRemaining 도 meter 가 없다. javadoc 은 그것이 기록된다고 말한다

§17.1 과 같은 형태가 GrpcRpcObservation 에도 있고, 이쪽은 클래스 javadoc 이 명시적으로 어긋난다.

"{@code deadlineRemaining} and {@code queueWaitTime} are recorded because they are the two numbers that explain a latency change without being latency. A p99 that doubles during a rollout is a different incident depending on whether callers were queueing."

두 값을 함께 들면서 "기록된다"고 단언하는데, record(GrpcRpcObservation) 이 등록하는 meter 는 넷이다.

Timer.builder(RPC_DURATION)record(observation.duration());
registry.counter(RPC_ATTEMPTS, tags).increment(observation.attempts());
if (requiresReconciliation())        registry.counter(COMPLETION_UNKNOWN, tags).increment();
if (!observation.queueWaitTime().isZero()) Timer.builder(QUEUE_WAIT)record(observation.queueWaitTime());

queueWaitTimeQUEUE_WAIT 타이머로 나간다. deadlineRemaining 은 나가는 곳이 없다 — meter 이름 상수 일곱 개 중에도 마감 잔량에 해당하는 것이 없고, tags() 에도 들어가지 않는다(태그로 넣으면 카디널리티가 터지므로 그것이 옳다).

그래서 이 성분을 읽는 코드는 unusedDeadline() 하나이고, 그 메서드의 production 호출자는 0 이다(§12.1).

§4.6 과의 관계. §4.6 은 이 성분의 검증 비대칭(음수 허용)이 "마감을 넘긴 호출을 표현하기 위한 것" 이라고 읽었다. 그 해석은 그대로 유효하다 — 다만 그 표현이 도달하는 곳이 아직 없다. 관측값으로서는 §17.1 의 queueHighWatermark 와 같은 처지다.

수정. queueWaitTime 과 같은 형태로 타이머를 하나 더 둔다(마감을 넘긴 경우는 unusedDeadline() 이 이미 빈 값으로 구분해 주므로 기록 대상에서 빼면 된다). 아니면 javadoc 의 "recorded" 를 "carried" 로 낮춘다. 지금은 관측 대상 둘을 나란히 약속하고 하나만 내보낸다.

17.2 P3 — 허용 태그 8개 중 둘은 값이 자유 문자열이고, 그중 하나는 bounded 열거형이 이미 존재한다

값 검사는 키가 allowlist 를 통과한 뒤 UNBOUNDED_VALUE 세 형태만 본다. 그런데 태그 값의 출처는 균일하지 않다.

태그 값 출처 유계
grpc.service · grpc.method GrpcMethodName 서비스/메서드 수만큼
grpc.rpc_type · grpc.status · grpc.completion_outcome 열거형
grpc.retry_bucket retryBucket() 4값
grpc.channel_profile String (null 이면 "server") 아니오
grpc.stream_termination_reason String, 비어 있지 않기만 하면 됨 아니오

GrpcStreamObservation 의 검증은 terminationReason 이 널이 아니고 공백이 아닌지만 본다. 호출자가 예외 메시지나 원격 상태 문자열을 그대로 넣으면 그 태그의 값 공간이 트래픽과 함께 자란다 — 이 클래스가 존재하는 이유로 든 바로 그 실패다.

그리고 그 개념의 bounded 열거형이 이미 저장소에 있다 — grpc-policyGrpcStreamTerminationReason.

쓰지 않은 이유는 의존 방향으로 설명된다. 이 리프의 allowed_dependencies["grpc-core-api"] 뿐이고 그 열거형은 grpc-policy 에 있다. 그래서 수정은 열거형을 grpc-core-api 로 옮기거나, violations 가 두 자유 문자열 태그에 대해 허용값 집합을 받도록 서명을 넓히는 것이다.

확인된 설계(문제 아님)
  • 논리 RPC / 물리 시도 / 스트림 수명주기를 구별한 것. 재시도가 있는 시스템에서 호출 한 번이 무엇인지가 층위마다 다르고, record 구현이 그 구별을 실제로 지킨다.
  • 거절이 드롭이 아니라 던지기인 것. boundedTags 의 javadoc 이 이유를 적는다 — 드롭하면 넣은 쪽이 계속 쓰고 첫 증상이 프로덕션 백엔드의 시계열 거부가 된다.
  • FORBIDDEN_TAGS 를 진단 전용으로 둔 것. 판정은 allowlist 가 하고, 이 목록은 왜 거절인지만 바꾼다.
  • 스트림 id 를 두 겹으로 막은 것. tags() 에서 빼고 FORBIDDEN_TAGS 에도 둔다.
  • long → double 확대 변환을 명시하고 이유를 주석에 적은 것.

Source anchors

src/grpc/grpc-observability/build.gradle:1-12
main/…/observability/GrpcMetricCardinalityPolicy.java:1-123
main/…/observability/GrpcObservationConvention.java:1-99
main/…/observability/GrpcRpcObservation.java:1-78
main/…/observability/GrpcStreamObservation.java:1-54
test/…/observability/GrpcMetricCardinalityPolicyTest.java:1-172
src/grpc/grpc-policy/…/streaming/GrpcStreamTerminationReason.java (대비)
src/messaging/messaging-observability/…/CardinalityGuard.java (대비)

A20-GRPC-OPERATION-LEDGER-JPA. grpc-operation-ledger-jpa

분석 중에는 grpc/GRPC-OPERATION-LEDGER-JPA.md 파일이었다. 226줄.

grpc-operation-ledger-jpa 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 3파일과 마이그레이션 1개 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-operation-ledger-jpa SSOT owner: grpc-operation-ledger-jpa integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • allowed_dependencies: ["grpc-core-api"]
  • runtime_memberships: [] — build-only
파일 LOC 성격
GrpcOperationLedgerEntity 155 JPA 엔티티 + 상태 전이
JpaGrpcOperationLedger 93 포트 구현 (insert-first 주장)
GrpcOperationLedgerRepository 28 Spring Data 인터페이스 (메서드 4개)
V001__create_grpc_operation_ledger.sql 39 테이블 + 제약 4 + 인덱스 1
GrpcOperationLedgerRepositoryTest 208 테스트 (인메모리 이중)
build.gradle 17 project 1 + vendor 2
Coverage ledger
scope count disposition reason
main/java/** 3 FULL_READ 155+93+28 전 본문
main/resources/db/migration/grpc/*.sql 1 FULL_READ 39줄 전문
test/java/** 1 FULL_READ 208줄, 인메모리 이중 구현 포함
build.gradle 1 FULL_READ 17줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-8
// Durable mutation idempotency: the operation ledger entity, its state machine, the vendor-neutral
// repository port and the Spring Data JPA binding, plus the migration that owns the unique
// constraint the whole contract rests on.
// … Tests here run against a hand-rolled in-memory port implementation — a real datastore is only
// justified when vendor semantics are the thing under test, and the constraint is asserted by the migration.

마지막 문장이 이 리프의 검증 전략을 규정한다. §17.1 이 그 전략의 경계를 다룬다.

2. 스키마가 계약이다

CONSTRAINT pk_grpc_operation_ledger PRIMARY KEY (storage_key),
CONSTRAINT uq_grpc_operation_ledger_identity
    UNIQUE (caller_fingerprint, full_method_name, idempotency_key_hash),
CONSTRAINT ck_grpc_operation_ledger_state
    CHECK (state IN ('IN_PROGRESS', 'COMMITTED', 'FAILED_TERMINAL')),
CONSTRAINT ck_grpc_operation_ledger_committed_has_outcome
    CHECK (state <> 'COMMITTED' OR outcome_reference IS NOT NULL),
CONSTRAINT ck_grpc_operation_ledger_terminal_has_completion
    CHECK (state = 'IN_PROGRESS' OR completed_at IS NOT NULL)

마이그레이션 헤더가 왜 애플리케이션 검사가 아니라 제약인지 적는다.

"A uniqueness check in application code instead would be a read followed by a write, with a window between them precisely as wide as the race it is meant to close."

커밋 행이 결과를 반드시 갖는다는 검사를 자바 record 와 DB 양쪽에 둔 이유도 적혀 있다 — 마이그레이션·백필·지원 스크립트가 쓴 행은 record 를 지나지 않는다.

전용 Flyway 위치(db/migration/grpc)를 쓰는 이유도 적혀 있다. gRPC 플랫폼을 채택하지 않은 배포가 이 테이블을 만들도록 강요받지 않기 위해서다.

3. 저장 키와 유니크 제약이 같은 행을 가리킨다

GrpcOperationIdentity(grpc-core-api):

public String storageKey() {
  return callerFingerprint + "|" + method.canonical() + "|" + idempotencyKeyHash;
}

즉 기본 키는 유니크 제약의 세 컬럼을 이어 붙인 파생값이다. 엔티티 javadoc 이 그 이중 저장을 설명한다 — 복합 쪽이 원자성을 주고, 파생 키가 조회에 단일 컬럼 기본 키를 준다.

같은 신원의 두 번째 청구는 같은 기본 키 행을 겨냥한다. §17.1 이 그 사실에서 나온다.

4. 좁은 저장소 인터페이스

Repository 를 확장하고 네 메서드만 이름 짓는다.

"JpaRepository publishes deleteAll, findAll and saveAll on the table that decides whether a payment runs twice."

5. 어댑터의 주장

JpaGrpcOperationLedger javadoc:

"claim is insert-first, read-on-conflict — not read-then-insert. That ordering is the whole adapter… A read-first implementation has a window between the read and the insert that is exactly as wide as the race it is supposed to close, and it passes every test that does not run the two attempts concurrently."

트랜잭션 애너테이션이 없는 이유도 적혀 있다 — 커밋은 호출자의 업무 트랜잭션 안에서 일어나야 하고 REQUIRES_NEW 는 변경이 내구적인데 청구는 아닌 창을 다시 만든다.

6. 상태 전이

IN_PROGRESS 에서만 전이할 수 있다(requireInProgress). 커밋은 결과 참조가 비면 거부한다. EnumType.STRING 을 쓰는 이유가 javadoc 에 있다 — 서수 컬럼은 열거형에 값이 끼어들면 저장된 모든 행을 조용히 다른 값으로 만든다.

10. 테스트 레인

11개 테스트. 인메모리 저장소 이중이 putIfAbsent 로 기존 행이 있으면 DataIntegrityViolationException 을 던진다 — INSERT + 유니크 제약의 동작을 모사한다.

마지막 테스트가 마이그레이션 파일을 직접 읽어 유니크 제약 문장이 있는지 단언한다.

12. negative-space probes

12.1 도달성. build-only. JpaGrpcOperationLedger 를 만드는 production 코드가 없다.

12.2 마이그레이션 적용 경로. db/migration/grpc 를 가리키는 설정이 저장소에 없다. main 설정 어디에도 spring.flyway.locations 가 없고(app-bootstrap 의 네 프로파일 yml 전수 확인), 그 경로를 이름으로 부르는 것은 이 리프의 테스트 한 곳뿐이다. messaging 가족이 §7.2 에서 기록한 것과 같은 형태다.

12.4 드리프트. build.gradle 주석이 서술한 네 요소(엔티티·상태 기계·포트·Spring Data 바인딩)와 마이그레이션이 전부 존재한다. 드리프트 없음.

16. 확인하지 못한 것

  • 실제 데이터베이스로 claim 을 두 번 돌려 §17.1 을 재현하지 않았다. Spring Data JPA 의 save 계약과 이 엔티티의 식별자 형태로 판정했다.
  • 동시 청구를 실제 커넥션 둘로 재현하지 않았다.

17. 손볼 것

17.1 P2 — insert-first 주장이 Spring Data 의 save 계약과 어긋난다. 그리고 테스트 이중이 그 차이를 가린다

어댑터는 이렇게 쓴다.

try {
  repository.save(GrpcOperationLedgerEntity.claim(identity, requestFingerprint, now));
  return Optional.empty();                       // 내가 이겼다
} catch (DataIntegrityViolationException alreadyClaimed) {
  return repository.findById(identity.storageKey()).map(entity -> entity.toRecord(identity));
}

전제는 save 가 INSERT 이고, 같은 신원의 두 번째 청구가 유니크 제약을 건드린다는 것이다.

그러나 이 엔티티의 식별자는 호출자가 배정한다. claim(...) 팩토리가 storageKeyidentity.storageKey() 로 채우므로 @Id 가 널이 아니다. Spring Data JPA 의 SimpleJpaRepository.save 는 식별자가 널이 아닌 엔티티를 새 것으로 보지 않고 EntityManager.merge 로 보낸다.

그리고 §3 에서 확인했듯 기본 키는 유니크 제약의 세 컬럼에서 파생된다. 같은 신원의 두 번째 청구는 같은 행을 겨냥한다.

따라서 실제 JPA 에서 일어나는 일은 이렇다.

  1. 두 번째 청구가 merge 로 들어간다. 그 행은 이미 존재한다.
  2. 유니크 제약이 발화하지 않는다. 새 행을 넣는 것이 아니라 같은 행을 갱신하기 때문이다.
  3. 분리 상태의 새 엔티티가 기존 행 위에 복사된다 — stateIN_PROGRESS, outcome_reference 는 널, completed_at 은 널, claimed_at 은 지금.
  4. 예외가 없으므로 claimOptional.empty() 를 돌려준다. 호출자는 자기가 청구를 소유했다고 읽는다.

즉 이미 커밋된 연산의 결과 참조가 지워지고, 재시도가 그 변경을 다시 실행한다. 이 모듈이 존재하는 이유로 든 바로 그 결과다.

세 CHECK 제약도 이것을 막지 못한다. 갱신 후 상태는 IN_PROGRESS + completed_at 널이라 전부 합법이다.

덧붙여 merge 는 즉시 flush 하지 않으므로, 서로 다른 트랜잭션의 진짜 경합에서 제약 위반이 나더라도 그것은 flush 나 커밋 시점에 도착한다 — try 블록 밖이다.

테스트가 이것을 볼 수 없는 이유. 인메모리 이중의 save 는 키가 이미 있으면 예외를 던진다.

GrpcOperationLedgerEntity existing = rows.putIfAbsent(entity.getStorageKey(), entity);
if (existing != null && existing != entity) { throw new DataIntegrityViolationException(...); }

즉 이중은 INSERT 를, 실제 저장소는 UPSERT 를 한다. build.gradle 주석이 실제 데이터스토어를 쓰지 않는 근거로 "vendor semantics 가 시험 대상일 때만 정당하다" 고 적었는데, 여기서 어긋난 것이 정확히 vendor semantics 다.

등급. 이 리프는 build-only 이고 어떤 배포도 이 어댑터를 조립하지 않는다. 그래서 오늘의 사고는 아니다. 배선하는 순간 성립한다.

수정. 셋 중 하나다.

  • 엔티티가 Persistable<String> 을 구현해 isNew() 를 명시한다. 신규 여부를 어댑터가 안다.
  • 저장소에 @Modifying @Query 로 명시적 INSERT 를 두고 save 를 청구 경로에서 쓰지 않는다.
  • 청구를 INSERT … ON CONFLICT DO NOTHING 의 영향 행 수로 판정한다.

어느 쪽이든 테스트 이중이 아니라 실제 데이터베이스에서 두 번 청구하는 계약 테스트가 함께 필요하다.

17.2 P3 — 낙관적 잠금 컬럼이 없어 전이 가드가 메모리 안에만 있다

requireInProgress() 가 두 번째 종결 전이를 막는다. 그 가드는 한 영속성 컨텍스트 안의 인스턴스 상태에만 적용된다. 엔티티에 @Version 이 없으므로 두 트랜잭션이 같은 행을 읽어 각각 전이하면 나중 쓰기가 앞의 것을 덮는다.

DB 의 세 CHECK 제약은 행의 모양을 지키지 지 전이 순서를 지키지 않는다. COMMITTED 행이 다른 결과 참조로 갱신되는 것을 막는 제약이 없다.

청구가 배타적이라는 설계 전제 아래서는 도달성이 낮다. 다만 §17.1 을 고치면 이 전제가 실제로 성립하는지가 함께 확인되어야 한다.

17.3 P3 — markCommitted 는 던지고 markFailed 는 조용히 넘어간다
markCommitted  findById(...).orElseThrow(IllegalStateException)   // 청구 없으면 실패
markFailed     findById(...).ifPresent(entity -> )                // 청구 없으면 무동작

커밋 쪽의 근거는 자바독에 있다 — 청구 없이 커밋하면 변경은 내구적이고 보호받지 못한다.

실패 쪽에는 근거가 없다. 청구가 사라진 뒤 도착한 종결 실패가 아무 흔적도 남기지 않는다. 회수가 청구를 지운 뒤 원래 소유자가 실패를 기록하려는 경우가 그 형태다. 의도라면 그 이유를 자바독에 적어야 하고, 아니라면 커밋 쪽과 같게 다뤄야 한다.

확인된 설계(문제 아님)
  • 유니크 제약을 애플리케이션 검사 대신 쓰기로 한 판단과 그 근거.
  • 커밋 행이 결과를 갖는다는 규칙을 record 와 DB 양쪽에 둔 것 — 마이그레이션·백필·지원 스크립트는 record 를 지나지 않는다.
  • Repository 를 확장해 네 메서드만 노출한 것.
  • EnumType.STRING — 서수 컬럼의 조용한 재지정을 피한다.
  • 전용 Flyway 위치 — 채택하지 않은 배포에 테이블을 강요하지 않는다.
  • 트랜잭션 애너테이션을 두지 않은 것과 그 근거.
  • 상태·완료 시각 CHECK 제약 — 종결 상태는 완료 시각을 갖는다.

Source anchors

src/grpc/grpc-operation-ledger-jpa/build.gradle:1-17
main/java/…/ledger/JpaGrpcOperationLedger.java:1-93
main/java/…/ledger/GrpcOperationLedgerEntity.java:1-155
main/java/…/ledger/GrpcOperationLedgerRepository.java:1-28
main/resources/db/migration/grpc/V001__create_grpc_operation_ledger.sql:1-39
test/java/…/ledger/GrpcOperationLedgerRepositoryTest.java:1-208
src/grpc/grpc-core-api/…/ledger/GrpcOperationIdentity.java:36-38
src/app-bootstrap/src/main/resources/application*.yml (flyway locations 부재 확인)

A20-GRPC-POLICY. grpc-policy

분석 중에는 grpc/GRPC-POLICY.md 파일이었다. 389줄.

grpc-policy 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) — src/main production 62파일 4,781줄 + src/test 18파일 2,800줄 축자 통독 완료. STRUCTURAL_ONLYgradle.lockfile 하나. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-policy SSOT owner: grpc-policy integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • runtime_memberships: [] — build-only
  • vendor: io.grpc BOM 을 모듈 범위로 가져온다
// build.gradle:6-8
// io.grpc versions are NOT managed by the Spring Boot BOM and this repo has no version catalog, so
// the grpc-bom is imported at MODULE scope from the root `ext.grpcVersion` SSOT — the same shape
// `adapter:inbound:grpc` uses, keeping the strict-locking blast radius local.
패키지 파일 주제
streaming 19 1,440 봉투·재개 토큰·직렬 기록기·간극 탐지·흐름 제어·수명·승인·심박
resilience 11 809 재시도 설정·예산·조정자·결정·자격·소유권·소유권 검증·서비스 설정·wait-for-ready 3종
idempotency 8 627 결정·인터셉터·지문·결과 재생·완료 조정·완료 판정·연산 상태·상태 질의
deadline 6 402 계산기·정책 검증기·취소 조정자·취소 가능 연산·취소 사유·의존 예산
error 4 407 매퍼·노출 정책·리치 상세·상태 매핑
policy 4 314 적재물 경계·메시지 크기·압축 프로파일·크기 위반
security 4 329 TLS 프로파일·인증 프로파일·자격 세대·회전 관리자
context 3 229 문맥 결속기·전파 정책·스냅숏
validation 3 284 전송 검증기·위반·protovalidate 인터셉터

main 총 62파일 / 4,781줄.

Coverage ledger
scope count disposition reason
main/java/** 62 FULL_READ 4,781줄. 패키지 9개의 전 파일 본문
test/java/** 18 FULL_READ 2,800줄. 패키지 9개 전부
build.gradle 1 FULL_READ 23줄 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일 — 생성물이고 의미 없는 좌표 반복

UNCLASSIFIED 0.

이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판의 패키지 표는 합이 47이었다 — streaming 을 9, resilience 를 7 로 적고 security·validation3+ 라고 썼다. 전 파일을 열지 않은 채 적힌 수였고, 그 표가 곧 통독이 끝나지 않았다는 증거였다. 아래 §17.6–17.8 은 나머지 15파일을 읽고 나서야 나온 것이다.


1. 오류 매퍼 — 클라이언트는 메시지 문자열을 읽지 않는다

"The rule it exists to hold is that a client never reads a message string. Everything a caller needs to branch on is a code, an ErrorInfo.reason, or a typed detail; the description is for a human reading a log and is replaced wholesale whenever it is not provably safe."

"An unrecognised exception becomes INTERNAL with an opaque execution id and nothing else. The id is the entire bridge between what the client saw and what the operator can find, and it is generated rather than derived so that it cannot accidentally encode a key or a row id."

실행 식별자 공급자가 주입되는 이유도 적혀 있다 — 무작위 값을 단언하지 않고도 그 식별자가 트레일러에 닿는 것을 테스트가 확인할 수 있게 하기 위해서다.

2. 적재물 경계 — 자원이 아니라 구조의 문제

"The binary rule is the one with an architectural reason behind it rather than a resource one. This repository already has a file server and an object store; a method that accepts a file as bytes duplicates their responsibility, loses their resumability and lifecycle, and puts the file in a request that has to be buffered whole to be parsed."

그리고 도달할 수 없는 설정을 생성자가 거부한다 — 인라인 이진 임계값이 메시지 상한보다 크면 결코 발화하지 않는다.

3. 재개 토큰 — 서명하고, 구분자를 봉인한다

GrpcResumeToken 의 아홉 성분 각각이 왜 필요한지가 javadoc 에 있다 — 스냅숏 판본 없이는 사라진 뷰의 위치에서 재개하고, 만료 없이는 이력이 사라진 커서에서 재개하고, 필터 지문 없이는 남의 필터를 자기 위치에서 재개해 요청하지 않은 행을 받는다.

그리고 문자열 성분이 구분자를 담지 못하게 생성자가 거부한다.

if (value.indexOf('|') >= 0) {
  throw new IllegalArgumentException(what + " must not contain '|', which separates the token's fields");
}

GrpcResumeTokenCodec 의 검증이 세 성질을 지킨다 — 상수 시간 비교(MessageDigest.isEqual), 알 수 없는 키 식별자 거부, 세 실패의 구분 불가.

"A codec that retries verification with every key it holds turns key rotation into a window in which a token signed by a compromised key still verifies."

"The three are deliberately indistinguishable to a caller: telling them apart is a probing oracle."

4. 재시도 예산 — 이 가족의 원자성 정본

public boolean tryConsume() {
  while (true) {
    long observed = tokens.get();
    if (observed < tokensPerRetry) { return false; }
    if (tokens.compareAndSet(observed, observed - tokensPerRetry)) { return true; }
  }
}

같은 문제를 이 리프의 GrpcStreamAdmissiongrpc-serverGrpcAdmissionController 는 비원자적으로 푼다(§17.1).

5. 자격증명 회전 — 준비 후 교체 후 배수

"Replacing the material in place is what produces the failure this exists to avoid: every call that was mid-flight when the swap happened fails with an authentication error that looks, from the client, exactly like a credential that was never valid."

같은 세대를 다시 적용하는 것은 무동작이다 — 재시도된 회전이 첫 회전이 받아들인 호출을 취소하면 안 되기 때문이다.

10. 테스트 레인

18파일 2,800줄. 패키지 9개 전부에 테스트가 있고, 배치는 균등하지 않다.

패키지 테스트
streaming 5 675
resilience 3 630
idempotency 3 397
validation 1 240
error 1 182
security 1 163
context 1 156
deadline 2 227
policy 1 130

idempotency 의 세 번째 파일은 테스트가 아니라 손으로 쓴 원장 이중 InMemoryOperationLedger 이고, 그 javadoc 이 자기 존재 이유를 적어 둔다.

"putIfAbsent on a concurrent map is the in-memory equivalent of the unique constraint the real adapter relies on, so the claim is atomic here for the same reason it is there. A fake that read and then wrote would let the policy tests pass while the property they exist to check does not hold."

즉 이 리프의 멱등 테스트는 원장의 원자성을 가정한다. 그 가정이 실제 어댑터에서 성립하는지는 grpc-operation-ledger-jpa §17.1 의 주제이고, 그 리프의 판정은 성립하지 않는다는 것이다. 이중이 production 보다 엄격하다.

이 리프는 동시성 테스트를 쓸 줄 안다. GrpcSerializedStreamWriterTest.concurrentProducersDoNotTouchTheTransport 는 생산자 8개로 400회를 밀어 넣고 순서·중복 없음을 단언한다. 그래서 §17.1·§17.2·§17.6 의 경합이 단일 스레드로만 시험되는 것은 능력의 한계가 아니라 선택이다.

12. negative-space probes

12.1 도달성. build-only. grpc-spring-boot-starter 가 이 리프의 타입 중 문맥 결속기와 오류 매퍼만 빈으로 만든다. 인터셉터 둘(ProtovalidateGrpcInterceptor·GrpcIdempotencyInterceptor)과 스트림 계열 19파일 전부는 조립되지 않는다. 이 리프의 모든 판정 등급이 그래서 한 칸 낮다 — 오늘의 사고가 아니라 배선하는 날의 사고다.

12.2 대조군 — 원자성 셋. 같은 저장소 안에 세 구현이 있다.

구현 형태
GrpcRetryBudget.tryConsume 비교 후 교체 루프 — 정확
GrpcStreamAdmission.tryAdmit 읽고 비교한 뒤 별도 증가 — §17.1
GrpcAdmissionController.tryAdmit(grpc-server) 같은 형태

12.3 대조군 — 배수 플래그. 저장소 전체에서 volatile boolean 은 정확히 둘이다.

grpc-client/…/GrpcChannelRuntime.java:20      private volatile boolean draining;
grpc-admin/…/GrpcServiceHealthRegistry.java:25 private volatile boolean draining;

같은 뜻의 세 번째 플래그가 GrpcStreamLifecycleCoordinator.drainSignalled 인데 여기에는 volatile 이 없다(§17.6). 같은 저장소가 같은 문제를 두 번은 표시하고 한 번은 표시하지 않았다.

12.4 드리프트. build.gradle 이 서술한 아홉 주제가 전부 패키지로 존재한다. 파일 수의 분포는 균등하지 않다 — streaming 하나가 main 의 30%(19/62)다.

12.5 테스트가 볼 수 없는 것. 세 곳에서 테스트의 형태가 결함을 구조적으로 가린다.

결함 가리는 형태
§17.4 DROP_OLDEST 바이트 계산 writer(policy, messageSize)() -> messageSize 상수 크기 공급자를 넘긴다. 모든 메시지가 같은 크기면 잘못 뺀 값과 옳은 값이 같다
§17.1 승인 경계 경합 streamAdmissionBoundsTotalAndPerCaller 가 단일 스레드다
§17.6 배수 신호 가시성 aDrainOutranksTheTimerssignalDrain()terminationDue() 를 같은 스레드에서 부른다

12.6 설정처럼 보이지만 상수인 것. GrpcContextPropagationPolicy.clearAfterTask 는 두 값을 받는 성분인데 생성자가 false 를 무조건 거부한다. 합법 값이 하나뿐이고, 그 값을 읽는 production 코드도 없다(§17.8).

16. 확인하지 못한 것

  • 어떤 인터셉터도 실제 서버에 걸어 돌리지 않았다. 배선 경로가 없다.
  • 동시 회전·동시 해제·동시 승인을 실행으로 재현하지 않았다. 원자성 분석과 JMM 으로 판정했다.
  • §17.6 의 가시성 실패를 관측하지 않았다. volatile 부재와 두 호출자의 스레드 소속으로 판정했다. 관측하려면 배수 스레드와 스트림 틱 스레드를 분리한 반복 시험이 필요하고, 이런 실패는 재현되지 않는 것이 정상이다.
  • §17.7 의 IPv6 누출을 실제 예외 메시지로 재현하지 않았다. 거부 목록 아홉 패턴을 전부 읽고 IPv4 점표기 외에 주소 형태를 보는 패턴이 없음을 확인해 판정했다.
  • gradle.lockfile 은 읽지 않았다(STRUCTURAL_ONLY).

17. 손볼 것

17.1 P2 — 스트림 승인의 경계가 동시성 아래에서 새고, caller별 맵이 줄지 않는다
AtomicInteger callerCount = perCaller.computeIfAbsent(callerFingerprint, key -> new AtomicInteger());
if (callerCount.get() >= maxStreamsPerCaller) { return false; }
if (openStreams.get() >= maxConcurrentStreams) { return false; }
callerCount.incrementAndGet();
openStreams.incrementAndGet();

읽고 비교한 뒤 별도로 증가한다. 경계에 있는 N 개 스레드가 모두 통과한다.

이 클래스의 javadoc 이 서술하는 실패 상황이 곧 고동시성이다 — "a client that reconnects on every error opens streams faster than the old ones close." 재접속 폭풍에서 경계가 가장 많이 샌다.

release 도 같은 형태라 음수로 갈 수 있다.

그리고 perCaller 에서 항목이 제거되지 않는다. computeIfAbsent 가 호출자 지문마다 계수기를 만들고 release 는 값만 줄인다. 서로 다른 호출자 수만큼 맵이 자란다 — grpc-observabilityGrpcMetricCardinalityPolicy 가 지표 태그에 대해 명시적으로 막는 것과 같은 종류의 증가이고, 여기에는 그 가드가 없다.

정본이 같은 리프에 있다 — GrpcRetryBudget.tryConsume 의 비교 후 교체 루프.

17.2 P2 — 자격증명 회전이 비교 후 교체가 아니고, 배수 완료가 진행 중인 회전을 되돌릴 수 있다
State observed = state.get();

state.set(new State(next, observed.current(), deadline));      // rotate

public void completeDrain() {
  State observed = state.get();
  state.set(new State(observed.current(), null, null));        // completeDrain
}

AtomicReference 를 쓰면서 두 메서드 모두 읽고 나서 조건 없이 쓴다.

두 결과가 다르다.

회전 경합. 두 회전이 같은 observed 를 읽으면 둘 다 승계 검사를 통과할 수 있고, 나중 set 이 앞의 것을 덮는다. 덮인 회전이 배수 대상으로 기록해 둔 세대가 상태에서 사라진다. 그 세대 위의 호출은 아무도 배수하지 않는다.

javadoc 이 이 상황을 이미 알고 있다 — 승계 검사의 존재 이유로 "the usual reason for one is two rotators racing" 를 든다. 검사는 있고 원자성이 없다.

배수 완료의 되돌림이 더 무겁다. completeDrain() 이 자기가 읽은 observed.current() 로 새 상태를 만든다. 읽기와 쓰기 사이에 회전이 일어나면, 그 회전이 활성화한 세대가 지워지고 이전 세대가 다시 현재가 된다. 즉 방금 교체된 자격증명이 되살아난다.

클래스의 존재 이유가 "in-flight 작업을 떨어뜨리지 않고 자격 자재를 교체하는 것" 인데, 이 경로는 교체 자체를 되돌린다.

수정은 두 메서드를 비교 후 교체로 바꾸는 것이다. rotatecompareAndSet(observed, next) 가 실패하면 다시 읽어 판정하고, completeDrainupdateAndGet(s -> new State(s.current(), null, null)) 로 현재 값을 원자적으로 읽어 쓰면 된다. 후자는 한 줄이다.

같은 형태가 grpc-clientGrpcChannelRuntimeRegistry.rotate 에도 있다. 두 리프가 같은 자료구조를 같은 방식으로 잘못 쓴다.

17.3 P2 — 결과 재생 저장소에 제거 경로가 없다
private final ConcurrentMap<String, byte[]> storedOutcomes = new ConcurrentHashMap<>();

maxInlineBytes 는 항목 하나의 크기를 제한하고, 개수를 제한하는 것은 없다. remove·clear·축출·만료가 전부 없다. size() 만 있고 그 값을 읽는 곳도 없다.

store 는 멱등 키가 필요한 메서드가 커밋될 때마다 불리므로 프로세스 수명 동안 커밋 수만큼 쌓인다.

javadoc 이 이 저장소를 "a small inline store" 라 부르는데 작게 유지하는 장치가 없다. 크기를 넘는 응답은 거부하면서 개수는 거부하지 않는다.

이웃 리프의 자매 클래스가 같은 문제를 명시적으로 다룬다 — GrpcClientMessageDeduplicator(advanced-streaming)의 javadoc 이 "A set grows without bound for the life of a session" 을 집합 방식을 거부한 이유로 들고, endSession() 으로 세션 단위 정리를 한다.

다만 그 자매 클래스도 절반만 지킨다. 재통독에서 확인했다 — 체크포인트 맵은 세션당 항목 하나로 유지되지만, 형제인 replayableOutcomes 는 적용된 메시지마다 항목을 쌓고 endSession 전까지 줄지 않는다. 그 리프의 §17.1 이 그것을 자기 판정으로 기록한다. 그러므로 이 자리의 대조는 "저쪽은 풀었고 이쪽은 안 풀었다" 가 아니라 "두 리프가 같은 형태의 무제한 증가를 갖고 있고, 한쪽만 세션 경계라는 부분적 상한을 갖는다" 이다.

17.4 P2 — 직렬 스트림 기록기의 가장 오래된 것 버리기가 잘못된 메시지의 바이트를 뺀다
case DROP_OLDEST -> {
  GrpcStreamEnvelope<T> dropped = queue.pollFirst();
  if (dropped != null) {
    queuedBytes = Math.max(0L, queuedBytes - nextBytes);   // ← 들어오는 메시지의 크기
    droppedMessages++;
  }
  enqueue(kind, payload, snapshotVersion, resumeToken, nextBytes);
  yield GrpcStreamWriteResult.DROPPED;
}

버려지는 것은 꺼낸 봉투인데 빼는 값은 새 메시지의 크기다. 봉투는 크기를 성분으로 담지 않으므로 이 지점에서 버려지는 크기를 알 방법이 없다.

계산을 따라가면 이렇다. 한 번의 DROP_OLDEST 마다 queuedBytesnextBytes 만큼 빠졌다가 enqueue 에서 같은 값만큼 다시 더해진다 — 순변화 0. 그런데 큐의 실제 내용은 nextBytes - droppedBytes 만큼 바뀐다. 그 차이가 매 낙차마다 쌓인다.

방향은 둘 다 틀렸다. 들어오는 메시지가 버려지는 것보다 크면 추적값이 실제보다 낮아져 바이트 경계가 늦게 발화한다(메모리). 반대면 실제보다 높아져 경계가 이르게 발화한다(불필요한 종료·낙차). 누적 바이트는 흐름 제어 정책의 판정 입력이고, 바이트 경계의 존재 이유가 javadoc 에 있다 — 개수 경계만 있으면 메모리 한도를 가장 큰 메시지가 정한다.

범위는 flush 창 하나다. flush() 가 큐를 비우면서 queuedBytes = 0L 로 되돌리므로 오차가 flush 를 건너 누적되지는 않는다. 그래서 이것은 영구 드리프트가 아니라 한 flush 주기 안의 폭주 구간에서 바이트 경계를 잘못 판정하는 결함이다. 낙차가 일어나는 상황이 곧 소비자가 못 따라가는 상황이고, 그때 flush 간격이 가장 길어진다.

17.5 P2 — 완료 조정자가 요청 경로에서 동기화 없는 가변 리스트를 변경한다
private final List<PendingCase> pending = new ArrayList<>();

pending.add(new PendingCase(...));   // reconcile(...) — 요청 경로
List.copyOf(pending);                // pendingCases()
pending.remove(resolved);            // clearPending(...)

synchronized·Concurrent*·volatile·Lock 전부 0 이고 단일 스레드 전용 표기도 없다. 같은 리프의 GrpcSerializedStreamWriter 는 아홉 마커로 제대로 닫혀 있어, 이 리프가 동시성을 인지하고 있음을 보여 준다.

reconcile 은 완료 결과가 불확실한 호출마다 불린다 — 장애 상황에서 동시에 몰리는 경로다. 그리고 pending 이 담는 것은 사람이 조정해야 하는 연산 목록이므로, 유실은 조정되지 않은 채 잊히는 연산이 된다.

17.6 P2 — 스트림 수명 조정자의 배수 신호가 스레드를 건너면서 volatile 이 아니다
private boolean drainSignalled;

public void signalDrain() { drainSignalled = true; }

public Optional<GrpcStreamTerminationReason> terminationDue(Instant now, Instant credentialExpiry) {
  
  if (drainSignalled) { return Optional.of(GrpcStreamTerminationReason.SERVER_DRAIN); }

두 메서드의 호출자가 다른 스레드다. signalDrain() 은 서버가 내려갈 때 종료 훅이 부르고, terminationDue(...) 는 스트림 자신의 틱에서 불린다 — 클래스 javadoc 이 검사 순서를 "then drain, because a server that has been told to stop should stop before its own timers fire" 로 규정한 그 틱이다.

평범한 boolean 이고 volatile·synchronized·AtomicBoolean 어느 것도 없다. 자바 메모리 모델 아래서 틱 스레드가 이 쓰기를 관측할 보장이 없다. 관측하지 못하면 스트림은 배수 명령을 받고도 계속 돌고, 최대 수명(기본 1시간)이 차야 끝난다.

같은 저장소가 같은 뜻의 플래그를 두 번은 volatile 로 적었다(§12.3). 세 번째만 빠졌다.

수정은 volatile boolean 한 단어다. heartbeatlastActivity 는 같은 문제가 아니다 — 스트림 틱 스레드만 만진다.

17.7 P3 — 오류 노출 거부 목록의 "호스트와 포트" 규칙이 IPv4 점표기만 본다
Pattern.compile("\\b\\d{1,3}(\\.\\d{1,3}){3}(:\\d{1,5})?\\b"),

아홉 패턴을 전부 읽으면 주소 형태를 보는 것은 이 하나다. 클래스 javadoc 은 거부 대상을 "a stack frame, a SQL fragment, a JDBC URL, a bearer token, a host and port, a file path" 로 서술하는데, 실제로 걸리는 host 는 IPv4 점표기뿐이다.

통과하는 것들:

  • IPv6 리터럴 — fe80::1, [2001:db8::1]:5432
  • DNS 이름과 포트 — documents-db.internal:5432, kafka-0.kafka-headless:9092

jdbc:postgresql://db/app 이 막히는 것은 host 규칙이 아니라 jdbc: 규칙 때문이다. 즉 이 구멍은 테스트에도 없다 — exposurePolicyRefusesLeakyStrings 의 아홉 사례 중 주소는 upstream 10.0.3.14:5432 refused 하나이고 IPv4 다.

닿는 경로는 mapUnknown 이다. 인식되지 않은 예외의 메시지를 safeToExpose 가 통과시키면 그대로 클라이언트로 간다. IPv6 클러스터나 쿠버네티스 서비스 이름을 쓰는 배포에서 상류 좌표가 밖으로 나간다.

등급이 P3 인 이유는 두 가지다. 이 리프가 build-only 라 오늘 닿지 않고, 노출되는 것이 자격증명이 아니라 내부 좌표다. 다만 이 정책이 존재하는 이유 자체가 "부분 마스킹이 아니라 통째 교체" 이므로, 목록에 빠진 형태는 통째로 통과한다.

17.8 P3 — clearAfterTask 는 합법 값이 하나뿐인 성분이고, 아무도 읽지 않는다
public record GrpcContextPropagationPolicy(
    boolean failClosedWithoutContext, boolean clearAfterTask) {
  
  public GrpcContextPropagationPolicy {
    if (!clearAfterTask) { throw new IllegalArgumentException("context must be cleared after every task; …"); }
  }
}

false 를 무조건 거부하므로 이 성분이 가질 수 있는 값은 true 하나다. 그리고 저장소 전체에서 clearAfterTask() 를 읽는 production 코드가 없다 — 호출처는 이 생성자의 가드와 테스트의 단언 한 줄뿐이다.

읽지 않아도 되는 이유는 GrpcContextBinder 가 옳게 쓰였기 때문이다. runWith·callWith·wrap 이 전부 finally 에서 detach 한다. 불변식이 이미 구조로 지켜진다.

그래서 이 성분은 설정처럼 보이지만 설정이 아니다. 읽는 사람은 정책으로 끌 수 있는 것이라고 읽고, 테스트는 그 가드를 시험한다.

수정은 성분을 지우고 javadoc 에 "always cleared" 를 남기는 것이다. 그러면 backgroundWork()·stable() 이 인자 하나가 되고, 불변식은 검증이 아니라 구조가 된다.

확인된 설계(문제 아님)
  • 클라이언트가 메시지 문자열을 읽지 않는다는 규칙과, 인식되지 않은 예외의 불투명 처리.
  • 실행 식별자를 파생이 아니라 생성으로 만든 것 — 키나 행 식별자를 우연히 담을 수 없다.
  • 적재물 경계의 근거를 자원이 아니라 책임 중복으로 든 것.
  • 도달할 수 없는 임계값 설정을 생성자가 거부한 것.
  • 재개 토큰의 아홉 성분 각각에 이유를 붙인 것.
  • 토큰 문자열 성분이 구분자를 담지 못하게 한 것 — 같은 저장소의 web 지문이 이 프레이밍을 하지 않는 것과 대비된다.
  • 토큰 검증의 상수 시간 비교·키 식별자 거부·실패 구분 불가.
  • 재시도 예산의 비교 후 교체 루프.
  • 자격증명 회전의 준비-교체-배수 순서와 같은 세대 재적용의 무동작 처리.
  • BOM 을 모듈 범위로 가져와 잠금 파급을 지역화한 것.

Source anchors

src/grpc/grpc-policy/build.gradle:1-23
main/java/…/context/{GrpcContextBinder:1-122, GrpcContextPropagationPolicy:1-37, GrpcContextSnapshot:1-70}
main/java/…/deadline/{GrpcCancellableOperation:1-23, GrpcCancellationCoordinator:1-122, GrpcCancellationReason:1-48,
                      GrpcDeadlineCalculator:1-68, GrpcDeadlinePolicyValidator:1-93, GrpcDependencyBudget:1-48}
main/java/…/error/{GrpcErrorExposurePolicy:1-82, GrpcErrorMapper:1-179, GrpcRichErrorDetail:1-93, GrpcStatusMapping:1-53}
main/java/…/idempotency/{GrpcCompletionReconciler:1-93, GrpcCompletionResolution:1-52, GrpcIdempotencyDecision:1-95,
                         GrpcIdempotencyInterceptor:1-148, GrpcOperationStatus:1-32, GrpcOperationStatusQuery:1-90,
                         GrpcOutcomeReplay:1-62, GrpcRequestFingerprint:1-55}
main/java/…/policy/{GrpcCompressionProfile:1-50, GrpcMessageSizeProfile:1-57, GrpcPayloadBoundaryPolicy:1-161,
                    GrpcSizeViolation:1-46}
main/java/…/resilience/{GrpcMethodRetryConfig:1-99, GrpcRetryBudget:1-76, GrpcRetryCoordinator:1-126,
                        GrpcRetryDecision:1-64, GrpcRetryEligibility:1-103, GrpcRetryOwner:1-39,
                        GrpcRetryOwnershipValidator:1-79, GrpcServiceConfigPolicy:1-73,
                        GrpcWaitForReadyDecision:1-40, GrpcWaitForReadyProfile:1-54, GrpcWaitForReadyValidator:1-56}
main/java/…/security/{GrpcAuthenticationProfile:1-59, GrpcCredentialGeneration:1-47,
                      GrpcCredentialRotationManager:1-122, GrpcTlsProfile:1-101}
main/java/…/streaming/{GrpcFlowControlDecision:1-43, GrpcFlowControlPolicy:1-88, GrpcResumeDecision:1-57,
                       GrpcResumeToken:1-91, GrpcResumeTokenCodec:1-142, GrpcSerializedStreamWriter:1-176,
                       GrpcSlowConsumerPolicy:1-26, GrpcStreamAdmission:1-76, GrpcStreamEnvelope:1-128,
                       GrpcStreamGapDetector:1-104, GrpcStreamHeartbeat:1-62, GrpcStreamId:1-41,
                       GrpcStreamLifecycleCoordinator:1-82, GrpcStreamLifetimePolicy:1-71, GrpcStreamProfile:1-47,
                       GrpcStreamSequence:1-48, GrpcStreamTerminationReason:1-51, GrpcStreamWriteResult:1-25,
                       GrpcStreamWriterState:1-22}
main/java/…/validation/{GrpcTransportValidator:1-134, GrpcValidationViolation:1-35, ProtovalidateGrpcInterceptor:1-115}
test/java/…/{context:1, deadline:2, error:1, idempotency:3, policy:1, resilience:3, security:1, streaming:5, validation:1} — 18파일 2,800줄
grpc-client/…/GrpcChannelRuntime.java:20            (§12.3 대조)
grpc-admin/…/GrpcServiceHealthRegistry.java:25      (§12.3 대조)

A20-GRPC-PROTO-CONTRACT. grpc-proto-contract

분석 중에는 grpc/GRPC-PROTO-CONTRACT.md 파일이었다. 310줄.

grpc-proto-contract 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 3파일 605줄, 스키마/설정 리소스 5개, test 1파일 324줄 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-proto-contract SSOT owner: grpc-proto-contract integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • allowed_dependencies: ["grpc-core-api"]
  • runtime_memberships: []
파일 LOC 성격
GrpcProtoContractValidator 447 라인 스캐너 + 9개 규칙 판정
GrpcProtoStyleManifest 120 규칙을 데이터로 둔 record
GrpcProtoRuleViolation 38 위반 1건 record
main java 합계 605
error.proto 66 리치 오류 상세 5 메시지 + 열거형 1
stream.proto 57 스트림 공통 스키마
buf.yaml · buf.gen.yaml · buf.lock 생성 설정(이 리프는 protoc 을 돌리지 않는다)
GrpcProtoContractValidatorTest 324 테스트
build.gradle 12 의존 project 1
Coverage ledger
scope count disposition reason
main/java/** 3 FULL_READ 447+120+38 전 본문 축자 확인
main/resources/proto/**/*.proto 2 FULL_READ 66+57 전문
main/resources/proto/buf.* 3 FULL_READ 테스트가 단언하는 키 전수 확인
test/java/** 1 FULL_READ 324줄 · 테스트 13개
build.gradle 1 FULL_READ 12줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일; 선언 의존은 project 1 뿐임을 build.gradle 에서 확인

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

// build.gradle:3-9
// Schema source of truth: the `.proto` files plus the rule engine that judges them.
// No protobuf plugin and no protoc invocation here — … running protoc is a separate, gated decision.

이 리프는 스키마 원본과 그것을 판정하는 규칙 엔진을 함께 담는다. protoc 은 돌지 않는다.

검증기 javadoc 이 그 한계를 스스로 규정한다.

"A line scanner, not a Protobuf parser, and that is a deliberate limit rather than a shortcut. Everything this checks is a property of the source text a reviewer reads… Semantics that need a compiled descriptor belong to grpc-codegen's descriptor artifact."

2. 규칙 9개

상수 판정
PROTO3_SYNTAX syntax = "proto3" 필수
PACKAGE_VERSIONED <org>.<domain>.v<major> — 접두 일치 + .*\.v[1-9]\d*$
JAVA_MULTIPLE_FILES option java_multiple_files = true 필수
JAVA_PACKAGE_SEPARATE java_package 가 손으로 쓴 패키지 안이면 위반
ENUM_ZERO_UNSPECIFIED 0 값 이름이 _UNSPECIFIED 로 끝나야 함
RESERVED_HISTORY 삭제 이력의 번호·이름이 reserved 에 있어야 함
WELL_KNOWN_TYPE_ALLOWLIST google/protobuf/ import 는 allowlist 에만
MAP_ALLOWLIST map 필드는 message.field 단위 허용
EXPLICIT_PRESENCE 매니페스트가 지정한 필드는 optional 선언 필수

3. 세 가지 설계 판단

3.1 금지가 아니라 allowlist

GrpcProtoStyleManifest javadoc:

"Three of the five fields are allowlists, and that shape is the decision: Any, Struct and map are not banned, they are things you have to ask for by name. A ban gets worked around; an allowlist entry gets read by the next person to open the manifest and carries the field it was granted for."

caSkeleton() 의 기본값은 조직 hyeonworks, 손으로 쓴 패키지 dev.caskeleton, WKT allowlist 5개(timestamp·duration·field_mask·empty·wrappers), map allowlist 빈 집합, presence 요구 빈 맵이다.

allowingWellKnownTypes · allowingMapFields · requiringPresence 세 메서드가 매니페스트를 넓힌 사본을 만든다. 정규 생성자가 모든 컬렉션을 복사하므로 리뷰를 통과한 매니페스트가 나중에 넓어지지 않는다.

3.2 던지지 않고 목록으로 돌려준다

GrpcProtoRuleViolation javadoc:

"Returned rather than thrown, and carrying a line number, because a schema review is a list. A validator that throws on the first violation turns 'this file breaks four rules' into four separate runs, and the author fixes them one at a time without ever seeing the shape of the problem."

3.3 삭제 이력은 추론하지 않고 입력으로 받는다

"a field that is simply gone from the current source is indistinguishable from one that never existed. Recording removals and checking them against reserved is the only way the 'do not reuse a field number' rule survives the commit that deletes the field."

SchemaHistory(removedFieldNumbers, removedFieldNames) 가 그 입력이고, 키는 파일에 적힌 메시지 이름이며 중첩은 점으로 한정한다.

4. 스캔 절차

한 줄씩 읽으면서 // 이후를 지우고, 빈 줄을 건너뛰고, 순서대로 시도한다 — syntax → package → import → option(파일 수준만) → 스코프 열기 → 닫기 → 스코프 안 멤버.

스코프는 message·enum·service·oneof 넷을 열고 이름을 점으로 한정해 스택에 쌓는다. 열거형 안에서는 0 값 이름을, 메시지와 oneof 안에서는 reserved·map·필드를 본다.

10. 테스트 레인

GrpcProtoContractValidatorTest 324줄. 첫 두 테스트가 이 리프의 게이트다.

  • committedSchemaIsCompliant — 커밋된 두 스키마를 실제로 검증기에 넣어 위반 0 을 단언한다.
  • theBufConfigurationAgreesWithTheValidatorbuf.yamlFILE·STANDARD 범주, 생성 경로가 build/generated/... 이고 out: src/ 가 아님, 버전 리터럴 부재, deps: [] 를 단언한다.

나머지는 규칙별 거부 사례다 — proto2 거부, 패키지 형식, 자바 패키지 충돌, java_multiple_files 부재, 열거형 0 값, 삭제 이력, WKT allowlist, map allowlist, explicit presence, 중첩 한정, describe() 렌더링. 전부 13개.

거부 사례 셋은 넓힌 매니페스트로 같은 소스를 다시 돌려 통과까지 확인한다 — allowingWellKnownTypes · allowingMapFields · requiringPresence. allowlist 라는 설계가 실제로 넓혀지는지까지 붙드는 형태다.

enumZeroValueNeedsTheUnspecifiedSuffix 는 줄 번호 7까지 단언한다 — 위반이 줄을 정확히 가리키는지가 이 리프의 산출물 형태(file:line rule — detail)에 직결되기 때문이다.

12. negative-space probes

12.1 도달성. 이 리프의 production 소비자는 0 이다. GrpcProtoContractValidator·GrpcProtoStyleManifest·GrpcProtoRuleViolation 을 부르는 코드는 자기 테스트뿐이다.

리프 밖 참조는 두 종류다.

참조 형태
grpc-codegen/…/GrpcBufPolicy.java:10 javadoc 언급
grpc-testkit · grpc-codegen · grpc-spring-boot-starter · grpc-advanced-edition 의 build.gradle project 의존 선언

네 모듈이 의존을 선언하지만 그중 어느 자바 파일도 이 리프의 타입을 import 하지 않는다. §17.4 가 그 결과를 다룬다.

12.3 아홉 규칙 중 둘은 이 저장소의 매니페스트에서 사실상 비활성이다.

public static GrpcProtoStyleManifest caSkeleton() {
  return new GrpcProtoStyleManifest("hyeonworks", Set.of("dev.caskeleton"),
      ALWAYS_ALLOWED_WELL_KNOWN_TYPES, Set.of(), Map.of());
  //                                   ^^^^^^^^  ^^^^^^^
  //                              mapFieldAllowlist  presenceRequiredFields
}
  • MAP_ALLOWLIST — 허용 목록이 비었으므로 실제 판정은 "map 전면 금지"다. §3.1 이 설명하는 "금지가 아니라 이름으로 요청" 이라는 형태는 매니페스트를 넓히는 호출자가 있어야 성립하는데, allowingMapFields 를 부르는 곳은 테스트뿐이다.
  • EXPLICIT_PRESENCE — 요구 맵이 비었으므로 어떤 필드도 optional 을 강제받지 않는다. 커밋된 두 스키마가 optional 을 다섯 곳에 쓰지만(예: FieldViolation.description, StreamEnvelope.resume_token) 그것을 요구하는 규칙은 없다. 규율이 코드가 아니라 저자의 손에 있다.

나머지 일곱은 기본 매니페스트에서도 실제로 판정한다.

12.2 대조군. 저장소에 .proto 파일이 넷 있다.

파일 이 검증기가 판정하는가
grpc-proto-contract/.../common/v1/error.proto 예 (테스트 목록)
grpc-proto-contract/.../common/v1/stream.proto 예 (테스트 목록)
messaging-schema-protobuf/src/test/proto/order_created_v1.proto 아니오
grpc-advanced-edition/.../edition2024/compatibility.proto 아니오

12.4 드리프트. build.gradle 주석이 규칙으로 든 다섯(proto3 + explicit optional, 패키지 버전, reserved 이력, 열거형 0 접미, WKT allowlist)이 전부 상수로 존재한다. 드리프트 없음.

16. 확인하지 못한 것

  • reserved 범위 문법의 오탐(§17.1)을 실행으로 재현하지 않았다. 정규식과 수집 코드로 판정했다.
  • 블록 주석(/* */) 안의 선언이 스캔되는지 실행으로 확인하지 않았다. LINE_COMMENT// 만 지우므로 그 형태가 남는다.
  • 테스트를 실행하지 않았다. 13개 전부 본문으로만 확인했다.
  • §17.4 의 "부르는 빌드가 없다"는 *.gradle · *.kts · *.yml 세 확장자와 자바 타입 이름 grep 으로 판정했다. 리플렉션이나 서비스 로더로 부르는 형태라면 잡히지 않는다.
  • 열거형 reserved 오탐(§17.5)을 실행으로 재현하지 않았다. 스코프 분기 코드로 판정했다.

17. 손볼 것

17.1 P3 — reserved 2 to 5; 범위가 개별 숫자로만 수집되어 RESERVED_HISTORY 오탐이 된다
RESERVED_NUMBERS = Pattern.compile("^\\s*reserved\\s+([^\";]*\\d[^\";]*);");
NUMBER           = Pattern.compile("\\d+");

Matcher number = NUMBER.matcher(reservedNumbers.group(1));
while (number.find()) { scan.reservedNumbersadd(Integer.valueOf(number.group())); }

reserved 2 to 5; 는 그룹이 "2 to 5" 이고 수집되는 것은 {2, 5} 다. 3·4 는 들어가지 않는다. reserved 9 to max;{9} 만 남는다.

그러면 삭제 이력이 3 을 담고 스키마가 reserved 2 to 5; 로 정확히 예약했는데도 RESERVED_HISTORY 위반이 보고된다. 범위 예약은 표준 문법이고 여러 필드를 한 번에 지울 때 쓰는 형태이므로 도달 가능하다.

수정은 to 를 인식해 범위를 펼치는 것이다. max 는 상한 상수로 다루거나 그 메시지에 대해 검사를 통과시킨다.

17.2 P3 — 반환 목록이 자바독이 약속한 source order 가 아니다
Scan scan = scan(fileName, source, violations);   // import·enum·map·presence 위반이 여기서 append
checkFileHeader(fileName, scan, violations);      // syntax·package·java_* 위반이 그 뒤에 append
checkRemovalHistory(fileName, scan, history, violations);

validate 의 javadoc 은 "@return every violation found, in source order" 라고 적는다. 실제로는 파일 앞머리의 syntax·package 위반이 40번째 줄의 map 위반보다 뒤에 온다.

describe()file:line rule — detail 형태를 만들고 그 형태의 목적이 빌드 로그를 읽는 것이므로, 정렬이 어긋나면 리뷰 목록으로서의 값이 줄어든다. 수정은 반환 직전에 line 으로 안정 정렬하는 것이다.

17.3 P3 — 커밋 스키마 게이트가 파일 목록을 하드코딩한다
List<String> files = List.of(
    "proto/hyeonworks/grpc/common/v1/error.proto",
    "proto/hyeonworks/grpc/common/v1/stream.proto");

리소스 디렉터리를 훑지 않는다. 이 리프에 세 번째 .proto 를 추가하면 이 테스트를 함께 고치기 전까지 판정되지 않고, 빌드는 초록으로 남는다.

같은 저장소가 다른 곳에서 이 형태를 이미 경계했다 — 빠뜨림이 통과가 되는 게이트다. 수정은 proto/** 아래 .proto 를 전부 열거해 돌리는 것이다.

기록 — oneof 도 스코프 이름을 밀어 넣는다 (현재 무해)

SCOPE_OPENoneof 를 스코프로 열고 이름을 점으로 한정한다. 그러면 message Foo { oneof kind { … } } 안의 필드는 Foo.kind.<field> 로 한정되고, SchemaHistory javadoc 이 말하는 키 규약(메시지 이름)과 어긋난다.

지금은 도달하지 않는다. protobuf 가 oneof 안에서 mapoptional 을 모두 금지하므로 MAP_ALLOWLIST·EXPLICIT_PRESENCE 판정이 그 자리에서 발생하지 않고, reservedoneof 안에 올 수 없다. 규칙을 넓힐 때 다시 볼 자리로 남긴다.

17.4 P2 — 두 파일이 이 검증기를 "빌드를 실패시키는 것" 이라고 단언하는데, 어떤 빌드도 그것을 부르지 않는다

같은 주장이 두 곳에 있다.

// grpc-codegen/…/GrpcBufPolicy.java:8-10
 * Buf's CLI is not part of this toolchain (adaptation D5), so the four lifecycle task names
 * below are the contract a CI environment fulfils and {@code GrpcProtoContractValidator} is what
 * actually fails a build here.
# grpc-proto-contract/…/proto/buf.yaml:3-5
# The rules named here are also implemented in GrpcProtoContractValidator, which is what actually
# fails this repository's build: the Buf CLI is not part of this toolchain, and a gate that silently
# passes when a binary is missing is worse than one that computes the same judgement from the
# committed schema.

두 문장이 같은 논증을 편다 — CLI 가 없으므로 이 자바 검증기가 그 자리를 대신한다는 것. 그런데 그 검증기를 부르는 빌드 코드가 없다.

$ grep -rn "GrpcProtoContractValidator" --include=*.gradle --include=*.kts --include=*.yml .
(매치 없음)
$ grep -rn "GrpcProtoContractValidator" --include=*.java src/ | grep -v grpc-proto-contract/
grpc-codegen/…/GrpcBufPolicy.java:10:   * … {@code GrpcProtoContractValidator} is what

Gradle 태스크도, 검증 훅도, 다른 모듈의 호출도 없다. 실제로 이 규칙 아홉 개를 실행하는 것은 GrpcProtoContractValidatorTest 하나이고, 그 테스트가 판정하는 대상은 §17.3 이 지적한 대로 하드코딩된 두 파일이다.

그래서 지금 성립하는 것과 성립하지 않는 것.

  • 성립: 이 리프에 커밋된 error.proto · stream.proto 는 매 빌드마다 아홉 규칙에 걸린다(테스트가 그것을 돌린다).
  • 성립하지 않음: "이 저장소의 빌드를 실패시킨다"는 범위. 리프 밖의 .proto 는 판정되지 않고(§12.2), 이 리프에 새로 추가되는 .proto 도 테스트 목록에 손으로 넣기 전까지 판정되지 않는다.

왜 P2 인가. 오작동이 아니라 주장과 배선의 불일치다. 그리고 그 주장이 CLI 부재를 정당화하는 논거로 쓰이고 있다 — "바이너리가 없을 때 조용히 통과하는 게이트보다 낫다"고 말하면서, 실제로 만든 것도 조용히 통과하는 게이트다. grpc-codegen §17.1 이 같은 형태를 반대편에서 기록했다(Buf 태스크 이름 넷이 어떤 빌드 파일에도 없다). 두 리프가 서로를 가리키며 상대가 게이트라고 말하는 모양이다.

수정. 두 가지 중 하나다.

  1. 배선한다 — check 에 물리는 Gradle 태스크가 proto/** 를 훑어 validate 를 돌리고 위반이 있으면 실패한다. §17.3 의 하드코딩도 함께 해소된다.
  2. 문장을 사실에 맞춘다 — "빌드를 실패시킨다"를 "이 리프의 테스트가 커밋된 스키마에 대해 실행한다"로 낮춘다. buf.yaml 과 GrpcBufPolicy 두 곳을 함께 고쳐야 한다.

낮추는 쪽을 고르더라도 §12.3 이 남는다 — 규칙 아홉 중 둘은 기본 매니페스트에서 판정할 것이 없다.

17.5 P3 — 열거형 안의 reserved 는 수집되지 않는다

scan 은 스코프 종류로 갈라진다.

if ("enum".equals(scopeKind)) {
  scanEnumValue(fileName, line, lineNumber, scopeName, violations);
} else if ("message".equals(scopeKind) || "oneof".equals(scopeKind)) {
  scanMessageMember(fileName, line, lineNumber, scopeName, scan, violations);
}

reserved 수집은 scanMessageMember 안에만 있다. proto3 는 열거형에도 reserved 2, 15;reserved "OLD_VALUE"; 를 허용하고, 열거형 값을 지울 때 번호를 예약하는 것은 필드와 같은 이유로 필요하다 — 예약하지 않고 재사용하면 옛 클라이언트가 보낸 정수가 다른 뜻으로 해석된다.

지금 SchemaHistory 에 열거형 이름으로 삭제 이력을 넣으면, 스키마가 정확히 예약했더라도 scan.reservedNumbers 에 그 이름이 없으므로 RESERVED_HISTORY 오탐이 난다. §17.1 의 범위 문법 문제와 같은 방향(fail-closed)이고 같은 자리에서 고칠 수 있다.

수정. reserved 수집을 스코프 종류와 무관하게 먼저 시도한 뒤 나머지 판정을 갈래로 보낸다.

확인된 설계(문제 아님)
  • 라인 스캐너라는 한계를 스스로 규정하고 그 경계 밖을 다른 리프로 넘긴 것.
  • 금지 대신 allowlist 를 고르고 그 이유를 적은 것 — 금지는 우회되고 allowlist 항목은 다음 사람이 읽는다.
  • 위반을 던지지 않고 목록으로 돌려주는 것 — 스키마 리뷰는 목록이다.
  • 삭제 이력을 입력으로 받는 것 — 사라진 필드는 없던 필드와 구분되지 않으므로 추론할 수 없다.
  • 커밋된 스키마 자체를 테스트가 검증기에 넣는 것 — 규칙이 자기 스키마에 실제로 적용된다.
  • buf 설정과 검증기가 같은 것을 말하는지 테스트가 붙드는 것.
  • 넓힌 매니페스트로 같은 소스를 다시 돌려 통과까지 확인하는 테스트 형태 — allowlist 라는 설계가 거부만이 아니라 허용도 실제로 하는지 붙든다.
  • buf.lock 을 빈 채로 커밋한 것과 그 근거 — "adding a first dependency is a visible diff in a file that already exists, instead of a new file nobody reviews."
  • buf.gen.yaml 에 판본 리터럴을 두지 않은 것 — 관리 플랫폼이 플러그인 판본을 소유한다는 GrpcCodegenManifest 의 규칙과 같은 결정이고, 테스트가 version: v1 부재로 그것을 붙든다.
  • 리치 오류 상세를 google.rpc.* 대신 자기 메시지로 소유한 것과 그 근거 — "the Stable contract is that a client branches on a code, a reason and a typed detail — never on a message string", 그리고 모양을 google.rpc 에 맞춰 두어 나중의 이전이 재설계가 아니라 이름 바꾸기가 되게 한 것.

Source anchors

src/grpc/grpc-proto-contract/build.gradle:1-12
main/java/…/contract/GrpcProtoContractValidator.java:1-447
main/java/…/contract/GrpcProtoStyleManifest.java:1-120
main/java/…/contract/GrpcProtoRuleViolation.java:1-38
main/resources/proto/hyeonworks/grpc/common/v1/error.proto:1-66
main/resources/proto/hyeonworks/grpc/common/v1/stream.proto:1-57
main/resources/proto/buf.yaml:1-17 · buf.gen.yaml:1-24 · buf.lock:1-12
grpc/grpc-codegen/…/GrpcBufPolicy.java:8-10 (게이트라고 주장하는 두 자리 중 하나)
test/java/…/contract/GrpcProtoContractValidatorTest.java:1-324

A20-GRPC-SERVER. grpc-server

분석 중에는 grpc/GRPC-SERVER.md 파일이었다. 277줄.

grpc-server 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 17파일 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-server SSOT owner: grpc-server integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • runtime_memberships: [] — build-only
  • vendor: grpc-api(BOM). Netty 의존 없음 — 프로파일은 설정 모델이지 배선이 아니다
파일 LOC
GrpcServerInterceptorChain 110
GrpcRawApiImportRule 108
GrpcAdmissionController 103
GrpcServiceAdapter · GrpcApplicationBoundaryRules 92 · 92
GrpcServerInterceptorOrder 90
GrpcServerProfile · GrpcNettyParityContract · GrpcExecutorProfile 84 · 69 · 66
GrpcNettyVariantSelector · GrpcServerInterceptorStage · GrpcServiceAdapterDescriptor 52 · 51 · 48
GrpcServerTransport · GrpcNettyVariant · GrpcApplicationInvocation · GrpcServiceAdapterMarker · GrpcResponseMapper 38 · 32 · 28 · 25 · 18
test 5파일 730
Coverage ledger
scope count disposition reason
main/java/** 17 FULL_READ 1,106줄 전 본문
test/java/** 5 FULL_READ 730줄
build.gradle 1 FULL_READ 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-6
// Server boundary: the ArchUnit-shaped application boundary rules, the typed service adapter SPI,
// the interceptor order contract, and the Netty server/executor/admission profiles.
//
// The Netty profiles are configuration models, not Netty wiring — no netty dependency here. Real
// Netty lives in `grpc-testkit`'s certification lane, which is where transport evidence is produced.

2. 인터셉터 순서 계약

열 단계이고 선언 순서가 계약이다. 각 위치의 이유가 열거형 javadoc 에 있다.

EXCEPTION_BOUNDARY → TRACE → AUTHENTICATION → ACTOR_TENANT → AUTHORIZATION
→ ADMISSION → DEADLINE_CANCELLATION → IDEMPOTENCY → VALIDATION → SERVICE_ADAPTER
  • 예외 경계가 가장 바깥 — 이후 단계의 실패가 매핑되지 않은 상태로 새지 않는다
  • 인증 → 행위자·소속 → 인가 — 각 단계가 앞 단계의 답을 필요로 한다
  • 승인이 마감보다 먼저 — 부하 중 서버가 일을 쓰기 전에 흘려보낸다
  • 멱등이 검증보다 먼저 — 재생된 요청이 이미 받아들인 본문을 다시 검증하지 않고 저장된 결과를 돌려준다
  • 검증이 어댑터 직전 — 사용 사례는 믿을 수 있는 메시지를 받는다

필수가 아닌 단계는 멱등 하나다 — 상태 변경 키 메서드가 없는 서버에는 할 일이 없기 때문이다.

3. 뒤집기가 이 클래스의 존재 이유다

"ServerInterceptors.intercept wraps each interceptor around the previous one, so the last one passed is the outermost at runtime — the opposite of how the order reads. Every codebase that builds this list by hand gets it backwards at least once, and the symptom is an exception boundary that catches nothing."

inStableOrder()inGrpcRegistrationOrder() 를 나누고, 후자가 전자의 역순임을 테스트가 붙든다.

4. 순서 검증의 근거

"A chain with validation before authentication lets an anonymous caller probe the schema through error messages; one with the exception boundary in the middle lets a throwable from an earlier stage escape as UNKNOWN. Neither shows up in a test of the happy path."

네 규칙이다 — 중복 단계, 필수 단계 누락, 역순, 예외 경계가 최외곽이 아님.

5. 원시 API 차단 규칙

금지 타입 열네 개가 채널·서버·호출을 손으로 만드는 구성 API 다. 금지가 아니라 허용 패키지 목록을 받는다.

"They are legitimate inside the platform and inside generated code, which is why this rule takes an allowlist of packages rather than banning them outright."

그리고 허용 목록이 비면 생성자가 거부한다 — 플랫폼 자신은 어딘가에서 채널을 만들어야 한다.

6. 응용 경계 규칙

금지 접두 열세 개와 금지 타입 셋. 접두만으로 너무 넓은 경우를 위해 정확한 타입 목록을 따로 둔다.

"a transport adapter that calls a repository has moved the use case into the transport, and the next caller of that use case — a scheduled job, a message consumer — either duplicates it or reaches through the controller."

10. 테스트 레인

다섯 테스트. 순서 계약(등록 역순 포함), 프로파일 거부(무제한 큐·in-process production·킵얼라이브·연결 수명), 승인 경계, 어댑터의 매핑, 경계 규칙과 원시 API 규칙을 확인한다.

12. negative-space probes

12.1 도달성. 이 리프의 다섯 타입은 저장소 어디에서도(자기 리프 밖) 참조되지 않는다.

GrpcApplicationBoundaryRules · GrpcRawApiImportRule · GrpcServiceAdapterMarker
GrpcServerInterceptorChain   · GrpcNettyParityContract      → leaf 밖 참조 0

GrpcAdmissionController·GrpcExecutorProfile·GrpcServerProfilegrpc-spring-boot-starter 가 빈으로 만들고, GrpcAdmissionControllergrpc-admin 의 배수 조정자가 협력자로 받는다.

다만 만들어지는 것과 불리는 것은 다르다. 재통독에서 다시 세었다.

tryAdmit()          production 호출 0   (테스트 3곳)
release()           production 호출 0
promoteFromQueue()  production 호출 0

즉 승인 제어기는 빈으로 존재하고 아무 호출도 승인받지 않는다. 그것을 부를 자리인 ADMISSION 인터셉터 단계의 구현이 이 가족에 없기 때문이다(§12.1 의 GrpcServerInterceptorChain 미참조와 같은 원인). §17.4 가 그 첫 호출자가 만나게 될 것을 다룬다.

12.4 드리프트. build.gradle 이 서술한 네 요소가 전부 존재하고, Netty 의존이 없다는 서술도 맞다.

16. 확인하지 못한 것

  • 실제 서버를 세워 인터셉터 사슬을 돌리지 않았다. 이 리프에 서버를 만드는 코드가 없다.
  • GrpcNettyParityContract 가 서술하는 두 변형의 동등성을 실행으로 확인하지 않았다. 그 클래스의 unproven(...) 을 부르는 코드도 저장소에 없다 — grpc-testkit §17.5 와 같은 형태의 평가기다.
  • §17.4 의 경합을 실행으로 재현하지 않았다. 읽기와 증가가 분리되어 있다는 것과 하한 가드가 없다는 것으로 판정했다.
  • gradle.lockfile 은 읽지 않았다(STRUCTURAL_ONLY).

17. 손볼 것

17.1 P2 — 두 아키텍처 규칙이 저장소 소스에 적용되지 않는다

GrpcApplicationBoundaryRules javadoc:

"The list is package prefixes rather than a prose rule, so it can be applied by an architecture test, by a source scan and by a review checklist without three people deciding what 'must not use a repository' covers."

세 적용처 중 저장소에 존재하는 것이 없다.

GrpcApplicationBoundaryRules  leaf 밖 참조 0
GrpcRawApiImportRule          leaf 밖 참조 0
GrpcServiceAdapterMarker      leaf 밖 참조 0   (규칙이 어댑터를 열거하려고 만든 마커)

그리고 이 리프의 테스트는 저장소 파일을 훑지 않는다. 인라인 소스 문자열을 넣는다.

assertThat(rule.violations("DocumentClient.java", applicationSource)).isNotEmpty();
assertThat(rule.violations("ChannelFactory.java", platformSource)).isEmpty();

즉 규칙의 판정 로직은 검증되지만, 저장소의 어떤 파일도 그 판정을 받지 않는다. GrpcServiceAdapterMarker 는 규칙이 어댑터를 런타임에 열거할 수 있도록 만든 애너테이션인데, 그것을 붙인 타입도 그것을 읽는 코드도 없다.

수정. 이 리프의 테스트에 저장소 소스를 훑는 검사를 추가한다 — src/**/*.java 를 읽어 GrpcRawApiImportRule.violations 를 돌리고 비어 있음을 단언하는 형태다. 규칙이 이미 파일 이름과 소스 텍스트를 받는 서명이므로 재료는 갖춰져 있다.

17.2 P3 — 원시 API 규칙이 import 문만 보므로 완전 수식 사용과 와일드카드를 놓친다
private static final Pattern IMPORT = Pattern.compile("^\\s*import\\s+(?:static\\s+)?([\\w.]+)\\s*;", MULTILINE);

if (RAW_API_TYPES.contains(imported)) { violations.add(); }

두 형태가 빠진다.

io.grpc.ManagedChannelBuilder.forAddress("h", 1).build();   // import 없이 완전 수식
import io.grpc.*;                                            // 정확 일치 실패

이것이 가정에 그치지 않는 이유는 이 저장소 자신의 문체다. 같은 가족의 여러 파일이 완전 수식 참조를 본문에 그대로 쓴다.

GrpcConsumerFixture         java.util.regex.Pattern.compile(...)
GrpcAdvancedSupportMatrix   java.util.stream.Collectors.toUnmodifiableMap(...)
GrpcProtoStyleManifest      java.util.Set / java.util.LinkedHashSet 인라인

즉 이 코드베이스에서 완전 수식 사용은 예외가 아니라 흔한 형태다.

규칙 클래스의 자바독은 "there is nothing to reach for" 를 목표로 든다. 지금 형태는 손이 닿는 경로 하나만 본다.

수정은 정규식을 타입 이름의 등장 자체로 넓히거나(오탐이 생기므로 주석·문자열 제거가 필요), 바이트코드 기반 검사로 옮기는 것이다. 후자가 이 저장소의 다른 아키텍처 게이트와 형태가 같다.

17.3 P3 — 빌더 경로에서 순서 규칙 넷 중 셋이 발화할 수 없다
public GrpcServerInterceptorChain build() {
  GrpcServerInterceptorOrder.requireStableOrder(List.copyOf(byStage.keySet()));
  
}

byStageEnumMap 이므로 keySet() 은 언제나 열거형 선언 순서다. 그리고 stage(...) 가 같은 단계의 두 번째 등록을 이미 거부한다.

따라서 빌더가 만드는 목록에서는 중복도, 역순도, 예외 경계가 최외곽이 아닌 경우도 발생할 수 없다. 발화 가능한 규칙은 필수 단계 누락 하나다.

결함은 아니다 — 나머지 셋은 violations(List) 를 직접 부르는 외부 호출자를 위한 것이고, 테스트가 그 경로로 셋을 모두 확인한다. 기록하는 이유는 빌더를 쓰는 조립 코드가 그 셋의 보호를 받는다고 읽기 쉽기 때문이다. 실제 보호는 자료구조가 준다.

17.4 P2 — 승인 제어기의 세 메서드가 원자적이지 않고, 큐 계수기를 되돌리는 경로가 없다

이 리프가 SSOT 이므로 여기에 적는다. grpc-policy §17.1 이 이 클래스를 대조군으로 지목하는데, 지목된 쪽 문서에 판정이 없었다.

첫째, 읽고 나서 따로 증가시킨다.

public Decision tryAdmit() {
  int running = inFlight.get();
  if (running < maxConcurrentCalls) {
    inFlight.incrementAndGet();          // ← 읽기와 증가 사이에 다른 스레드가 들어온다
    return new Decision(true, );
  }
  int waiting = queued.get();
  if (waiting < maxQueuedCalls) {
    queued.incrementAndGet();            // ← 같은 형태
    

경계에 있는 N 개 스레드가 모두 통과한다. AtomicInteger 를 쓰면서 비교와 증가를 나눈 형태이고, 같은 가족의 정본이 GrpcRetryBudget.tryConsume 의 비교 후 교체 루프다.

release()·promoteFromQueue() 도 같다 — get() > 0 을 확인한 뒤 별도로 감소시키므로, 두 스레드가 같은 마지막 하나를 보고 둘 다 감소시켜 음수가 될 수 있다. 클래스가 Math.max(0, …) 같은 하한도 두지 않는다.

둘째, 큐 계수기를 되돌리는 경로가 없다.

큐에 들어간 호출도 admitted=true 를 받는다. 그런데 그 경로는 queued 만 올리고 inFlight 는 올리지 않는다. 그리고 끝난 호출을 반납하는 메서드는 하나뿐이다.

public void release() {
  if (inFlight.get() > 0) { inFlight.decrementAndGet(); }   // ← queued 는 건드리지 않는다
}

따라서 호출자가 promoteFromQueue() 를 정확히 한 번 끼워 넣지 않으면 계수기가 어긋난다 — 큐에서 실행된 호출이 끝나면 queued 는 그대로이고 inFlight 만 줄어든다. releaseQueued() 같은 메서드도, 그 짝짓기를 요구하는 서술도 없다.

시험이 이것을 볼 수 없는 이유. 두 시험 모두 단일 스레드이고, releaseAndPromotionTrackCapacityrelease()promoteFromQueue()짝지어 부른다. 짝짓지 않는 경로는 시험되지 않는다.

등급. 오늘 호출자가 없으므로(§12.1) P2. 승인 단계를 배선하는 순간 P1 이다 — 부하 아래에서 경계가 새는 것과, 큐 계수기가 단조 증가해 at capacity 가 영구히 참이 되는 것이 함께 온다.

수정. 세 메서드를 비교 후 교체 루프로 바꾸고, 큐 경로에 대응하는 반납 메서드를 두거나 promoteFromQueuerelease 안으로 접는다.

확인된 설계(문제 아님)
  • 열 단계의 순서와 각 위치의 이유를 열거형 javadoc 에 적은 것.
  • 등록 순서 뒤집기를 클래스로 분리하고 그 이유를 적은 것.
  • 멱등만 선택 단계로 둔 것.
  • 순서 위반의 증상이 정상 경로 테스트에 나타나지 않는다는 근거.
  • 원시 API 를 금지가 아니라 허용 패키지 목록으로 다룬 것.
  • 허용 목록이 빈 규칙을 거부한 것.
  • 접두 목록으로 너무 넓은 경우를 위해 정확한 타입 목록을 따로 둔 것.
  • Netty 의존 없이 프로파일만 두고, 실제 전송 증거를 테스트킷 인증 레인으로 넘긴 것.

Source anchors

src/grpc/grpc-server/build.gradle:1-14
main/java/…/server/GrpcServerInterceptorChain.java:1-110
main/java/…/server/GrpcServerInterceptorOrder.java:1-90
main/java/…/server/GrpcServerInterceptorStage.java:1-51
main/java/…/architecture/GrpcRawApiImportRule.java:1-108
main/java/…/architecture/GrpcApplicationBoundaryRules.java:1-92
main/java/…/architecture/GrpcServiceAdapterMarker.java:1-25
main/java/…/server/(GrpcAdmissionController · GrpcServiceAdapter · GrpcServerProfile · GrpcExecutorProfile · GrpcNettyParityContract · GrpcNettyVariantSelector · GrpcServiceAdapterDescriptor · GrpcServerTransport · GrpcNettyVariant · GrpcApplicationInvocation · GrpcResponseMapper)
test/java/…/(architecture/GrpcApplicationBoundaryRulesTest · server/GrpcServerInterceptorOrderTest · server/GrpcServerProfileTest · server/GrpcServiceAdapterTest · server/GrpcNettyVariantSelectorTest)

A20-GRPC-SPRING-BOOT-STARTER. grpc-spring-boot-starter

분석 중에는 grpc/GRPC-SPRING-BOOT-STARTER.md 파일이었다. 295줄.

grpc-spring-boot-starter 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 4파일 468줄, 등록 파일 1개, test 1파일 267줄 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-spring-boot-starter SSOT owner: grpc-spring-boot-starter integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • runtime_memberships: [] — build-only
  • 선언 의존: api project 7 · implementation project 3 + vendor 2
파일 LOC 성격
GrpcPlatformStartupValidator 188 5개 검증 묶음, static 유틸
GrpcPlatformProperties 139 ca-skeleton.grpc.platform.* 결속
GrpcPlatformAutoConfiguration 106 빈 9개
GrpcPlatformConfigurationException 35 위반 목록 예외
main java 합계 468
AutoConfiguration.imports 1 자동 설정 1개 등록
GrpcPlatformStartupValidatorTest 267 테스트
build.gradle 24 의존 선언
Coverage ledger
scope count disposition reason
main/java/** 4 FULL_READ 188+139+106+35 전 본문
main/resources/META-INF/spring/*.imports 1 FULL_READ 1줄
test/java/** 1 FULL_READ 267줄 · 테스트 12개
build.gradle 1 FULL_READ 24줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체와 격리 규칙

// build.gradle:3-7
// The platform's composition boundary: typed properties, auto-configuration and the startup
// validator that refuses a deployment whose configuration contradicts a Stable invariant.
//
// It must never reach `:grpc-advanced:*`. That is not a comment — the registry's
// allowed_dependencies for this leaf omits every advanced id, `verifyCleanArchitectureDependencies`
// enforces it, and GrpcPlatformStartupValidatorTest asserts the same rule from the Java side.

격리 규칙은 세 겹이다 — 레지스트리, 빌드 검증 태스크, 그리고 자바 쪽 단언. 세 번째는 validateAdvancedIsolationGrpcStableBuildInvariant.requireNoAdvancedDependency 를 부르는 형태다.

2. 자동 설정이 만드는 것

@ConditionalOnProperty(prefix = "ca-skeleton.grpc.platform", name = "enabled", havingValue = "true", matchIfMissing = false) — 기본 꺼짐.

만들어지는 값
GrpcExecutorProfile boundedPool(maxPoolSize, queueCapacity)
GrpcServerProfile stableNetty(executorProfile)
GrpcAdmissionController forExecutor(executorProfile)
GrpcServiceHealthRegistry new …(GrpcHealthPolicy.standalone())
GrpcReflectionPolicy 설정 값이 없으면 defaultFor(environment)
GrpcAdminExposurePolicy standard()
GrpcDrainPolicy stable()
GrpcContextBinder new …(GrpcContextPropagationPolicy.stable())
GrpcErrorMapper new …("grpc-platform", UUID::randomUUID)

전부 정책·프로파일·레지스트리다. 서버도, 인터셉터 사슬도, 서비스 어댑터 등록도 없다.

3. 설정 표면

@ConfigurationProperties(prefix = "ca-skeleton.grpc.platform", ignoreUnknownFields = false).

두 판단이 javadoc 에 적혀 있다.

"Off by default, like every other optional capability in this repository. A platform that starts because its jar is on the classpath is a platform that opens a port on a deployment nobody decided to give one to."

"ignoreUnknownFields = false so a misspelled key fails startup rather than silently leaving a setting at its default."

4. 검증기가 담은 규칙

javadoc 이 선정 기준을 적는다.

"Every rule here is a mistake whose runtime symptom is either silence or a misattributed failure… None of them fails a smoke test."

다섯 묶음이다.

  • 전송·보안 — production 전송이 아니면 거부, 배포 환경에서 TLS 미사용·trust-all·반사 전체 공개 거부
  • 실행기 — 큐 용량 1 미만(무제한) 거부, 풀 크기 양수 요구
  • 메서드 — 단항인데 사용 가능한 마감이 0, 명시적 재시도가 멱등 프로파일과 모순, 멱등 키 필수인데 원장 비활성, Stable 범위 밖 RPC 종류
  • 채널 — Stable 스킴 요구, 두 재시도 소유자가 동시에 in-process 재시도
  • 고급 격리 — Stable 스타터가 advanced 의존을 끌면 위반

그리고 한 번에 전부 모아 실패한다 — "so a deployment learns the whole list in one restart."

10. 테스트 레인

GrpcPlatformStartupValidatorTest 267줄 · 12개. 검증기의 규칙별 거부와 통과를 직접 호출로 확인한다. 자동 설정 컨텍스트를 세우는 테스트는 없다ApplicationContextRunner 도, 슬라이스 테스트도 없다. 빈 아홉 개가 실제로 조립되는지는 이 레인이 답하지 않는다.

aCoherentConfigurationStarts 가 통과 쪽을, 나머지 아홉이 규칙별 거부 쪽을 잡는다 — in-process 전송, TLS 둘, 반사, 무제한 실행기, 원장 없는 멱등 키, client-streaming, 재시도 소유자 둘, advanced 누출. aRefusalNamesEveryViolation 이 세 개를 동시에 깨뜨려 목록이 한 번에 나오는지 본다.

마지막 하나가 형태로 특이하다.

void theAutoConfigurationIsRegisteredAndStable() {
  assertThat(read(Path.of("src/main/resources/META-INF/spring/…imports")).strip())
      .isEqualTo("dev.caskeleton.grpc.boot.GrpcPlatformAutoConfiguration");
  // The dependency declaration, not the word: the build file's own comment says it must never
  // reach an advanced module, and matching on the prose would fail on the sentence stating the rule.
  assertThat(read(Path.of("build.gradle"))).doesNotContain("project(':grpc-advanced");
}

단위 테스트가 자기 모듈의 build.gradle 을 파일로 읽어 의존 선언을 단언한다. 주석이 왜 낱말이 아니라 선언 문법에 맞추는지까지 적어 두었다 — 규칙을 서술한 문장 자체가 낱말 검색에 걸리기 때문이다. verifyCleanArchitectureDependencies 가 도는 것과 별개로 이 레인 안에서도 격리가 붙들린다.

12. negative-space probes

12.1 도달성. 리프 밖에서 이 리프의 타입을 부르는 코드가 0 이고, 이 리프를 의존하는 모듈도 0 이다.

$ grep -rn "grpc-spring-boot-starter" --include=*.gradle src/
(매치 없음)
$ grep -rn "ca-skeleton.grpc.platform" --include=*.yml --include=*.yaml --include=*.properties .
(매치 없음 — 이 리프 자신을 빼고)
$ grep -rn "GrpcPlatformAutoConfiguration\|GrpcPlatformProperties\|GrpcPlatformStartupValidator"       --include=*.java src/ | grep -v grpc-spring-boot-starter
(매치 없음)
타입 production 호출자
GrpcPlatformAutoConfiguration 등록 파일 1줄 — 그러나 이 스타터를 클래스패스에 올리는 모듈이 없다
GrpcPlatformStartupValidator 0
GrpcPlatformConfigurationException 검증기 안에서만

enabled=true 를 쓰는 설정 파일도 저장소에 없다. 즉 @ConditionalOnProperty 가 참이 되는 배포가 지금 하나도 없고, 아홉 빈은 아직 한 번도 만들어진 적이 없다. §17.1 의 등급을 P2 로 둔 근거가 이것이다 — 오늘의 사고가 아니라, 이 스타터를 처음 채택하는 배포가 맞을 상태다.

12.3 선언만 있고 쓰이지 않는 의존 셋.

implementation project(':grpc:grpc-proto-contract')
implementation project(':grpc:grpc-codegen')
implementation project(':grpc:grpc-operation-ledger-jpa')

이 리프의 자바 4파일 어디에도 dev.caskeleton.grpc.contract · …grpc.codegen · 운영 원장 타입의 import 가 없다. operation-ledger-enabledboolean 프로퍼티일 뿐 원장 타입을 참조하지 않는다.

세 의존 모두 build-only 판정 도구다 — 스키마 규칙 엔진, 코드 생성 거버넌스, JPA 원장. 스타터가 그것들을 런타임 조립에 쓰지 않으면서 클래스패스에 끌고 온다. 이 리프의 존재 이유가 "구성 경계"이므로, 경계가 끌어오는 것이 실제로 필요한 것인지가 다른 리프보다 더 중요하다.

같은 사실을 반대편에서도 기록해 두었다 — grpc-codegen §12.1, grpc-proto-contract §12.1.

12.2 설정 키별 소비자.

읽는 곳
enabled @ConditionalOnProperty
executor-queue-capacity · executor-max-pool-size 자동 설정 + 검증기
environment 자동 설정(반사 정책) + 검증기
reflection-mode 자동 설정 + 검증기
transport 검증기뿐
tls-enabled · trust-all-certificates 검증기뿐
operation-ledger-enabled 검증기뿐
default-unary-deadline 없음

12.4 드리프트. build.gradle 이 서술한 세 요소(타입 있는 설정·자동 설정·시작 검증기)가 전부 존재한다. 어긋난 것은 세 번째가 시작 시 돌지 않는다는 점이고 §17.1 이다.

16. 확인하지 못한 것

  • 스타터를 실제 애플리케이션에 올려 컨텍스트를 세우지 않았다. build-only 이고, 이 스타터를 의존하는 모듈이 저장소에 없다(§12.1).
  • verifyCleanArchitectureDependencies 태스크를 이 리비전에서 실행하지 않았다.
  • 테스트를 실행하지 않았다. 12개 전부 본문으로만 확인했다.
  • implementation 의존이 쓰이지 않는다는 것(§12.3)은 패키지 이름 grep 으로 판정했다. 상수나 문자열을 통한 간접 사용이라면 잡히지 않는다.

17. 손볼 것

17.1 P2 — 시작 검증기가 시작 시 실행되지 않는다

GrpcPlatformStartupValidator 를 이름으로 부르는 파일은 둘뿐이다 — 자기 자신과 자기 테스트.

src/grpc/grpc-spring-boot-starter/src/main/java/…/GrpcPlatformStartupValidator.java
src/grpc/grpc-spring-boot-starter/src/test/java/…/GrpcPlatformStartupValidatorTest.java

GrpcPlatformAutoConfiguration 은 빈 9개를 만들고 requireValid 를 부르지 않는다. 초기화 콜백도, @PostConstruct 도, ApplicationRunner 도 없다.

그래서 클래스 javadoc 이 약속한 성질이 성립하지 않는다 — "Refuses to start on a configuration that would be wrong in a way nobody would notice." 지금은 그 설정으로 그냥 시작한다.

함께 사라지는 것. 검증기가 유일한 소비자인 설정 키가 넷이다.

  • transport — production 이 아닌 전송을 거부할 곳이 없다. 게다가 자동 설정은 이 값을 보지 않고 GrpcServerProfile.stableNetty(...) 를 하드코딩한다(§17.2).
  • tls-enabled · trust-all-certificates — 배포 환경의 TLS 바닥을 강제할 곳이 없다.
  • operation-ledger-enabled — 멱등 키 필수 메서드가 원장 없이 열리는 것을 막을 곳이 없다.

같은 저장소가 이 형태를 두 번 기록했다 — WebPlatformStartupValidator 가 시작 시 실행되지 않고, BrokerAclManifest 의 시작 자기점검이 없다. 반대로 messaging 의 StartupProfileValidationInitializingBean.afterPropertiesSet 으로 돌려 그 문제를 이미 한 번 해결했고, fileserver 는 attestMapping() 을 app-bootstrap 의 @Bean 으로 연결했다. 정본이 저장소 안에 둘 있다.

왜 배선되지 않았는지가 서명에 보인다. violations 는 넷을 받는다.

public static List<String> violations(
    GrpcPlatformProperties properties,          // 자동 설정이 @EnableConfigurationProperties 로 가진다
    GrpcMethodPolicyCatalog catalog,            // 이 자동 설정에 빈 정의 없음
    List<GrpcNamedChannelProfile> channelProfiles,  // 빈 정의 없음
    Set<String> stableModuleDependencies)       // 이것을 런타임에 계산하는 코드가 저장소에 없음

넷 중 셋에 생산자가 없다. 특히 마지막은 "스타터가 해석한 모듈 id 집합" 인데, 그것을 실행 중에 산출하는 코드가 저장소 어디에도 없다 — 테스트는 Set.of("grpc-core-api", "grpc-policy", "grpc-server", "grpc-client") 리터럴을 넣는다. 검증기가 요구하는 입력을 구성 경계가 만들지 않으므로, 지금 형태로는 부를 수가 없다.

수정. 자동 설정에 검증기를 부르는 InitializingBean(또는 SmartInitializingSingleton) 빈을 하나 추가하되, 세 입력의 생산자를 함께 정한다.

  • GrpcMethodPolicyCatalog · List<GrpcNamedChannelProfile>ObjectProvider 로 받고 비어 있을 때의 동작(건너뛸지, 그 자체를 위반으로 볼지)을 정한다.
  • stableModuleDependencies — 런타임에 계산할 방법이 없다면 GrpcStableModuleCatalog 가 아는 정적 목록으로 대체하거나, 이 규칙을 빌드 태스크 쪽에만 남기고 검증기 서명에서 뺀다. 지금은 같은 불변식을 세 겹으로 둔다고 §1 이 말하지만, 세 번째 겹이 실행되려면 아무도 만들지 않는 입력이 필요하다.
17.2 P3 — 자동 설정이 transport 를 읽지 않고 전송을 하드코딩한다
@Bean @ConditionalOnMissingBean
public GrpcServerProfile grpcServerProfile(GrpcExecutorProfile executorProfile) {
  return GrpcServerProfile.stableNetty(executorProfile);
}

GrpcPlatformProperties.transportGrpcServerTransport 열거형이고 기본값이 NETTY_SHADED 다. 그 값을 자동 설정이 보지 않으므로 다른 값을 설정해도 만들어지는 프로파일은 같다.

지금은 무해에 가깝다 — 기본값이 하드코딩된 것과 같고, 다른 값은 §17.1 때문에 거부되지도 않지만 반영되지도 않는다. 그러나 설정 키가 존재하고 문서화되어 있으므로 운영자는 그것이 전송을 고른다고 읽는다.

수정은 프로파일 팩토리를 transport 로 분기시키거나, 그 키를 검증 전용임을 자바독에 명시하는 것이다.

17.3 P3 — default-unary-deadline 은 읽는 코드가 저장소에 없다
/** The default deadline applied to a Stable unary method that declares none. */
private Duration defaultUnaryDeadline = Duration.ofSeconds(2);

getDefaultUnaryDeadline() 의 호출자가 0 이다. 검증기도 이 값을 쓰지 않는다 — 검증기가 보는 것은 정책 목록의 policy.deadline().usable() 이고 그 값이 0 이면 위반을 낸다. 즉 자바독이 말하는 "선언하지 않은 메서드에 적용되는 기본 마감" 을 적용하는 코드가 없다.

ignoreUnknownFields = false 라서 이 키를 설정하는 것은 성공하고 아무 효과가 없다.

수정은 그 기본값을 실제로 적용하는 지점을 만들거나(정책 목록 조립 시), 필드를 제거하는 것이다.

17.4 P3 — 반사 모드를 명시하면 서비스·역할 허용 목록이 조용히 하드코딩으로 바뀐다
@Bean @ConditionalOnMissingBean
public GrpcReflectionPolicy grpcReflectionPolicy(GrpcPlatformProperties properties) {
  return properties.getReflectionMode() == null
      ? GrpcReflectionPolicy.defaultFor(properties.getEnvironment())
      : new GrpcReflectionPolicy(
          properties.getReflectionMode(),
          java.util.Set.of("admin"),                     // ← 리터럴
          java.util.Set.of("ROLE_PLATFORM_ADMIN"));      // ← 리터럴
}

두 갈래가 만드는 것이 같은 종류의 값이 아니다.

  • 설정하지 않으면 defaultFor(environment) — 환경이 서비스 목록과 역할 목록을 함께 결정한다.
  • 설정하면 모드만 운영자 것이고, 허용 서비스와 허용 역할은 이 자동 설정에 박힌 리터럴이 된다.

운영자가 조정한다고 생각하는 것은 노출 수위 하나인데, 실제로는 노출 대상 집합까지 바뀐다. 그리고 그 두 리터럴은 설정 표면에 노출되어 있지 않으므로 되돌릴 방법이 reflection-mode 를 다시 비우는 것뿐이다.

ca-skeleton.grpc.platformignoreUnknownFields = false 를 걸어 "오타가 조용히 기본값으로 남지 않게" 한 설정 표면이다. 같은 규율로 보면, 값을 하나 설정했을 때 설정하지 않은 두 값이 함께 바뀌는 것도 같은 종류의 침묵이다.

수정. 허용 서비스·역할을 GrpcPlatformProperties 에 올리거나, 명시 모드에서도 defaultFor(environment) 가 만든 정책의 모드만 바꾼 사본을 쓴다. 후자가 이 저장소의 다른 곳에서 쓰는 형태다(GrpcProtoStyleManifest.allowingWellKnownTypes 처럼 넓힌 사본).

확인된 설계(문제 아님)
  • 기본 꺼짐과 그 근거 — 클래스패스에 있다는 이유로 포트를 여는 플랫폼이 되지 않는다.
  • ignoreUnknownFields = false — 오타가 조용히 기본값으로 남지 않는다.
  • 고급 격리를 세 겹으로 둔 것 — 레지스트리·빌드 태스크·자바 단언.
  • 검증기가 한 번에 전부 보고하는 것 — 재시작 한 번으로 목록 전체를 배운다.
  • 검증 규칙 선정 기준 — 증상이 침묵이거나 오귀인인 실수만 담는다.
  • @ConditionalOnMissingBean 을 아홉 빈 전부에 둔 것 — 채택자가 개별 정책을 갈아끼울 수 있다.

Source anchors

src/grpc/grpc-spring-boot-starter/build.gradle:1-24
main/java/…/boot/GrpcPlatformAutoConfiguration.java:1-106
main/java/…/boot/GrpcPlatformStartupValidator.java:1-188
main/java/…/boot/GrpcPlatformProperties.java:1-139
main/java/…/boot/GrpcPlatformConfigurationException.java:1-35
main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:1
test/java/…/boot/GrpcPlatformStartupValidatorTest.java:1-267

A20-GRPC-TESTKIT. grpc-testkit

분석 중에는 grpc/GRPC-TESTKIT.md 파일이었다. 320줄.

grpc-testkit 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) — src/main production 26파일 2,313줄 + src/test 8파일 1,339줄 축자 통독 완료. STRUCTURAL_ONLYgradle.lockfile 하나. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-testkit SSOT owner: grpc-testkit integration/family document: §A20 (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • runtime_memberships: [] — build-only
  • 이 리프만 실제 전송을 싣는다 — grpc-inprocess, grpc-netty-shaded
파일 LOC 구획
testkit/netty/GrpcTlsTestMaterial 239 netty
testkit/inprocess/GrpcInProcessContractFixture 145 inprocess
testkit/netty/GrpcNettyTestServer 137 netty
testkit/netty/GrpcNettyTestClient 116 netty
testkit/fault/GrpcTransportEvidenceClassifier 107 fault
release/GrpcStableReleaseGate 96 release
performance/GrpcPerformanceGate 95 performance
testkit/inprocess/GrpcInProcessTestServer 94 inprocess
release/GrpcCompatibilityMatrix 93 release
testkit/netty/GrpcNettyContractProfile 87 netty
testkit/inprocess/GrpcInProcessTestClient 83 inprocess
testkit/GrpcUnaryScenario 82 testkit
testkit/GrpcStreamingContractResult · testkit/GrpcUnaryReliabilityContract 81 · 81 testkit
testkit/GrpcEvidenceGrade 80 testkit
testkit/GrpcStreamingScenario 79 testkit
performance/GrpcPerformanceBudget 78 performance
testkit/GrpcUnaryContractResult 69 testkit
performance/GrpcPerformanceResult · testkit/fault/GrpcFaultScenario 66 · 66 performance / fault
release/GrpcReleaseEvidence 63 release
testkit/GrpcTextCodec 62 testkit
testkit/GrpcServerStreamingContract 61 testkit
testkit/fault/GrpcFaultResult 58 fault
testkit/fault/GrpcFaultPoint 53 fault
release/GrpcReleaseDecision 42 release

구획별: testkit 8파일 595줄 · netty 4파일 579줄 · inprocess 3파일 322줄 · release 4파일 294줄 · fault 4파일 284줄 · performance 3파일 239줄.

main 총 26파일 / 2,313줄.

Coverage ledger
scope count disposition reason
main/java/** 26 FULL_READ 2,313줄. 위 표가 전부
test/java/** 8 FULL_READ 1,339줄
build.gradle 1 FULL_READ 66줄. 레인 선언 포함 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일 — 생성물

UNCLASSIFIED 0.

이 표는 2026-09-01 재통독에서 파일 단위로 다시 세었다. 이전 판은 구획 다섯의 근사치(189+·3+)로 적었고, 그 근사 안에 §17.3–§17.6 이 있었다. UNCLASSIFIED 0.


1. 네 레인이 모듈 넷을 대신한다

// build.gradle:3-7
// Certification. The Stable plan splits this across four modules (core / in-process / netty /
// fault); this repository already expresses "these two runs are not the same kind of evidence" with
// strict test lanes rather than with module boundaries, so the four become four lanes over one
// leaf. A lane that discovers nothing fails, and none of them can serve an up-to-date result —
// which is the property the split was protecting.
레인 태그 증거
grpcInProcessContractTest grpc-inprocess 어댑터·인터셉터 순서·상태·멱등 재생. HTTP/2·TLS·전송 한도는 절대 아님
grpcNettyContractTest grpc-netty 실제 소켓의 HTTP/2, TLS·상호 TLS, 메타데이터·메시지 하드 한도, GOAWAY, 킵얼라이브, 배수
grpcFaultTest grpc-fault 증거 경계마다의 연결 손실과, 관측되지 않은 상태에서 미전송을 추론하기를 거부하는 분류기
grpcPerformanceTest grpc-performance 지연 백분위·스트림 포화·실행기 포화·배수 예산

2. 증거 등급이 코드 안에서 구분을 유지한다

GrpcEvidenceGrade javadoc:

"a claim about TLS backed by CONTRACT evidence is refused, because in-process transport never negotiated one."

certifies() 를 필드가 아니라 계산으로 둔 이유도 적혀 있다 — 컬렉션 필드를 가진 열거형은 어떤 정적 분석에도 가변 열거형으로 보인다.

3. 성능 레인이 기본 test 에서 빠진 이유

// The performance lane is excluded from the default `test` task. It measures a running server
// under load, and a measurement in the release gate is a flaky test on a shared CI runner; it runs
// when somebody asks for it, by name.
tasks.named('test') { useJUnitPlatform { excludeTags 'grpc-performance' } }

측정을 릴리스 게이트에 넣지 않는다는 판단이 명시적이고, 그 대신 GrpcPerformanceGate 가 기록된 기준선과 대조하는 형태로 남는다.

4. 릴리스 게이트 — 문서가 후속이 아니라 차단 사유다

"A platform whose failure modes are COMPLETION_UNKNOWN and a stream that needs a full resync is a platform whose on-call has to be told what to do about them; shipping the behaviour and writing the runbook afterwards means the first person to meet it is the one who has to work it out at three in the morning."

차단 사유가 다섯 갈래다 — 호환성 표의 누락 결과, 생산되지 않은 증거 등급, 스키마 발행 거부, 런북 부재, 결정 기록 부재, 지원 표 부재.

requireCertified 는 능력이 이번 릴리스가 낸 증거로 인증되지 않으면 던지고, 메시지에 실제로 돈 등급을 나열한다.

10. 테스트 레인

여덟 테스트 1,339줄. 전송 증거 분류기(272줄)가 가장 크고, 그다음이 Netty 계약 프로파일과 단항 신뢰성 계약이다.

12. negative-space probes

12.1 도달성. 릴리스 게이트·성능 게이트·증거 타입의 소비자는 이 리프의 테스트뿐이다. 그리고 저장소 전체에서 dev.caskeleton.grpc.testkit 를 import 하는 파일이 이 리프 밖에 하나도 없다 — build.gradle 이 grpc 리프 열을 api 로 노출하는데, 그 픽스처를 쓰는 리프가 없다. 각 리프가 자기 픽스처를 따로 만든다.

12.2 대조군 — 이 저장소의 다른 인증 지형. messaging 가족은 인증 워크플로를 갖고 있고(messaging-certification), 이 가족은 갖고 있지 않다. §17.1.

12.3 "레인" 이 두 뜻으로 쓰인다.

출처 이름 개수
build.gradlestrictTestLanes grpcInProcessContractTest·grpcNettyContractTest·grpcFaultTest·grpcPerformanceTest 4
GrpcCompatibilityMatrix.caSkeleton() boot-managed-platform·proto3-explicit-optional·netty-shaded·netty-unshaded·upstream-grpc-java-override·protobuf-edition-2024·protobuf-edition-2026 7

교집합이 없다. missingResults(laneResults) 가 요구하는 키는 둘째 목록의 것이고, 그것을 만드는 코드는 자기 테스트뿐이다(§17.4).

12.4 드리프트. build.gradle 이 서술한 네 레인이 전부 등록되어 있고, 각각 정확히 하나의 @Tag 붙은 테스트 클래스를 갖는다 — grpc-inprocessGrpcInProcessContractFixtureTest, grpc-nettyGrpcNettyContractProfileTest, grpc-faultGrpcTransportEvidenceClassifierTest, grpc-performanceGrpcPerformanceLaneTest.

12.5 실제로 소켓을 여는 것과 리터럴로 만드는 것.

등급 실제 실행 결과 객체의 출처
CONTRACT in-process 서버·클라이언트 왕복 ✓ GrpcUnaryContractResult·GrpcStreamingContractResult전부 리터럴(§17.5)
TRANSPORT Netty 소켓·TLS·mTLS·한도·GOAWAY ✓ 결과 타입 없음. profile.grade() 만 단언한다
FAULT 소켓 하나를 작업 중에 죽인다 ✓ GrpcExecutionEvidence리터럴(§17.3)
PERFORMANCE 200회 측정 ✓ 측정값은 실제, 예산은 임시값·기준선 없음

16. 확인하지 못한 것

  • 성능 레인을 돌리지 않았다. 기본 test 에서 제외되어 있고 부하 측정이 필요하다.
  • 세 레인은 이전 분석에서 직접 실행해 통과를 확인했다(계약 7 · Netty 9 · 고장 9, 실패 0). 이번 재통독에서는 다시 돌리지 않았다.
  • §17.3 의 observedFailure 경로를 실행으로 확인하지 않았다. 대입과 단언이 같은 메서드 안에 있고 그 사이에 재대입이 없다는 것으로 판정했다.
  • keytool 명령줄 노출(§17.6)을 실제로 ps 로 관측하지 않았다. ProcessBuilder 인자 목록에 비밀번호가 들어간다는 것으로 판정했다.
  • gradle.lockfile 은 읽지 않았다(STRUCTURAL_ONLY).

17. 손볼 것

17.1 P2 — 네 레인이 check 에 붙지 않고, 이 가족을 이름으로 부르는 워크플로가 없다

ca.strict-test-lane.gradle 은 레인을 verification 그룹의 Test 태스크로 등록만 한다. check 에 연결하는 줄이 없다.

tasks.register(lane.name, Test) { group = 'verification'; … }
check dependsOn 관련 라인 → 0건

그리고 CI 워크플로에서 이 가족을 이름으로 부르는 것이 없다. ci-quality-gates.yml./gradlew check 를 돌리므로 각 리프의 기본 test 는 돈다(이번에 확인: classes=71 tests=579 failures=0). 네 증거 레인은 그 밖에 있다.

결과적으로 이 플랫폼의 CONTRACT·TRANSPORT·FAULT 등급을 뒷받침하는 것은 25개 테스트이고, 그 25개는 누군가 명령을 직접 입력할 때만 돈다.

build.gradle 자신이 그 위험을 적는다 — "A lane that discovers nothing fails, and none of them can serve an up-to-date result". 첫 성질은 레인 규약이 지킨다. 둘째 성질은 아무도 돌리지 않으면 무의미하다.

같은 저장소가 이 형태를 두 번 기록했다 — 모듈 18 의 "붉은 게이트는 마지막으로 돌린 사람이 본 것을 보고한다" 와 mongo 가족의 릴리스 게이트 지형. 차이는 이쪽 레인이 오늘 초록이라는 것이고, 그것을 확인한 방법이 이번 분석에서 직접 돌린 것이라는 점이다.

수정은 세 레인(성능 제외)을 check 에 붙이거나, messaging 가족처럼 전용 워크플로를 두는 것이다. 성능 레인을 빼는 판단은 이미 근거와 함께 코드에 있으므로 그대로 두면 된다.

17.2 P3 — 릴리스 게이트의 입력이 전부 호출자가 손으로 만드는 값이다
public GrpcReleaseDecision evaluate(
    GrpcReleaseEvidence evidence,
    Map<String, Boolean> laneResults,
    GrpcSchemaArtifactPublisher.PublishDecision schemaDecision)

세 입력 중 어느 것도 실제 레인 결과나 실제 산출물에서 오지 않는다. 게이트를 부르는 곳은 자기 테스트 하나뿐이고, 그 테스트가 세 값을 리터럴로 만든다.

이 형태 자체는 이 저장소의 다른 게이트와 다르다. mongo 가족의 증거 검증기는 테스트 결과 XML 을 읽고 파일의 수정 시각까지 본다. 이쪽 게이트는 그런 산출물 판독기를 갖지 않는다.

지금은 무해하다 — 릴리스 절차가 이 게이트를 부르지 않기 때문이다. 기록하는 이유는 §17.1 을 고쳐 레인을 자동으로 돌리게 되면, 그 결과를 이 게이트에 넣어 주는 코드가 함께 필요하다는 점이다.

17.3 P2 — 고장 레인의 유일한 실소켓 시험이 자기가 관측한 것을 버리고 리터럴로 증거를 만든다

GrpcTransportEvidenceClassifierTest.aRealConnectionLossAfterAppStartIsCompletionUnknown 은 이 리프에서 유일하게 실제 연결을 작업 중에 끊는다. 서버 핸들러가 래치로 멈춰 있는 동안 server.close() 를 부른다. 거기까지는 진짜 고장이다.

그런데 그 고장이 만들어 낸 관측이 어디에도 남지 않는다.

StatusRuntimeException observedFailure;
try (GrpcNettyTestClient client = ) {
  Thread caller = new Thread(() -> {
    try { client.callUnary(CREATE_DESCRIPTOR, "create"); }
    catch (StatusRuntimeException expected) {
      // The connection dies underneath this call; the exception is the observation.
    }                                     // ← 그 "observation" 을 버린다
  });
  
  observedFailure = null;                 // ← 무조건 null 을 대입한다
}

assertThat(observedFailure).isNull();     // ← 방금 대입한 null 을 단언한다

주석이 "the exception is the observation" 이라고 말하는데 그 예외는 catch 안에서 사라지고, 변수는 null 로 고정되고, 단언은 자기 대입을 확인한다.

그리고 GrpcFaultResult 에 들어가는 증거는 방금 일어난 호출에서 오지 않는다.

GrpcExecutionEvidence evidence = GrpcTransportEvidenceClassifier.classify(
    CREATE, RpcType.UNARY,
    GrpcTransportEvidenceClassifier.ClientObservation.sentAndSilent(),   // ← 리터럴 팩토리
    false, GrpcBusinessEvidence.ATTEMPTED);

즉 소켓은 실제로 죽었고, 그 죽음에서 읽어 낸 값은 하나도 쓰이지 않는다. 이 시험이 실제로 증명하는 것은 applicationStarted == true 하나다. 나머지는 분류기의 산술이고, 그것은 같은 파일의 다른 일곱 시험이 이미 소켓 없이 증명한다.

이 형태를 이 리프 자신이 이름 붙여 두었다.

"a release cannot cite an in-process run as transport evidence. That substitution is the easiest one to make under time pressure and the hardest to spot afterwards: the suite name says 'contract', the report says the platform is certified, and nothing in between records that no socket was opened." — GrpcReleaseEvidence

여기서는 소켓이 열렸다. 그런데 등급을 뒷받침해야 할 증거가 여전히 손으로 쓴 값이다. 한 단계 아래의 같은 치환이다.

수정. callUnary 를 부른 스레드가 잡은 예외와 그 시점의 진행 상태를 밖으로 넘겨(AtomicReference), 그것으로 ClientObservation 을 구성한다. 그러면 sendCompleted·responseHeadersReceived 가 관측값이 되고, 이 시험이 FAULT 등급을 실제로 뒷받침한다.

17.4 P3 — 호환성 표의 레인 이름과 빌드의 레인 이름이 서로 다른 집합이다

GrpcStableReleaseGate.evaluate 의 둘째 인자는 Map<String, Boolean> laneResults 이고, GrpcCompatibilityMatrix.missingResults 가 그 키를 자기 목록과 대조한다.

그 목록은 배포 조합의 이름이다 — boot-managed-platform, netty-shaded, upstream-grpc-java-override, protobuf-edition-2024

빌드가 등록하는 레인의 이름은 증거 종류다 — grpcInProcessContractTest, grpcNettyContractTest, grpcFaultTest, grpcPerformanceTest.

두 집합의 교집합이 비어 있다. 그래서 §17.1 을 고쳐 네 Gradle 레인을 check 에 붙이더라도, 그 결과가 이 게이트의 laneResults 를 채우지는 못한다 — 이름이 다른 축을 가리키기 때문이다. 게이트가 요구하는 것은 "Boot 관리 플랫폼 조합에서 돌았는가" 이고, 레인이 답할 수 있는 것은 "전송 증거를 냈는가" 다.

두 축이 다 필요하다는 것 자체는 옳다. 기록하는 이유는 §17.1·§17.2 의 수정이 이것까지 함께 다루지 않으면 게이트가 여전히 손으로 만든 값을 먹는다는 점이다.

17.5 P3 — 계약 스위트 둘이 결과를 만드는 코드를 갖지 않는다

GrpcUnaryReliabilityContractGrpcServerStreamingContract 는 순수 평가기다 — List<Result> 를 받아 위반을 돌려준다. 시나리오 정의(단항 3 · 스트리밍 5)와 그 정합성 검사는 훌륭하다. 스위트가 자기 커버리지를 열거하고, 돌지 않은 시나리오를 침묵이 아니라 위반으로 만든다.

빠진 것은 그 시나리오를 돌리는 쪽이다. GrpcUnaryContractResult·GrpcStreamingContractResult 를 만드는 코드는 저장소 전체에서 두 테스트뿐이고, 둘 다 리터럴로 만든다.

private static GrpcUnaryContractResult result(
    GrpcUnaryScenario scenario, int attempts, int invocations, GrpcCompletionOutcome outcome) {
  return new GrpcUnaryContractResult(scenario, GrpcEvidenceGrade.CONTRACT, attempts, invocations, outcome);
}

그래서 "이 플랫폼은 비멱등 변경을 재시도하지 않는다" 를 뒷받침하는 것은, 그 문장을 리터럴로 적은 뒤 평가기가 그것을 읽고 위반이 없다고 답하는 절차다. 평가기의 산술은 옳고, 대상이 관측이 아니다.

in-process 픽스처(§1)는 이 시나리오들을 돌릴 재료를 이미 갖고 있다 — 인터셉터를 끼운 서버, 상태 매핑, 스트리밍 핸들러. 수정은 픽스처 위에서 세 시나리오를 실행해 attempts·businessInvocations 를 세는 러너를 두는 것이다.

17.6 P3 — 던져 버릴 비밀번호를 만들어 놓고 외부 프로세스의 명령줄에 싣는다

GrpcTlsTestMaterial 이 상수 비밀번호를 피하는 이유를 세 줄로 적는다.

"A literal password in source is a literal password in source, and a scanner that flags it is right to — the cost of being correct here is three lines."

그리고 같은 클래스가 그 값을 keytool 인자로 넘긴다.

runKeytool(List.of("-genkeypair", , "-storepass", new String(password), "-keypass", new String(password)));

Process process = new ProcessBuilder(command).redirectErrorStream(true).start();

프로세스 명령줄은 같은 호스트의 다른 사용자가 ps/proc/<pid>/cmdline 로 읽을 수 있다. 소스 리터럴보다 관측 가능성이 오히려 높다.

영향은 작다 — 값이 매번 새로 만들어지고, 키스토어는 임시 디렉터리에 있으며 close() 가 지운다. 기록하는 이유는 이 클래스가 정확히 그 위험 계층을 스스로 논증했다는 점이다. 완화와 노출이 같은 메서드 안에 있다.

keytool-storepass:file-keypass:file 을 받는다. 임시 파일 하나면 명령줄에서 값이 사라진다.

확인된 설계(문제 아님)
  • 모듈 넷 대신 레인 넷으로 증거 종류를 분리하고, 그 대체의 근거를 적은 것.
  • 증거 등급을 열거형으로 두고 각 등급이 무엇을 인증할 수 있는지 계산으로 답한 것.
  • certifies() 를 필드가 아니라 계산으로 둔 것과 그 근거.
  • 성능 레인을 기본 test 에서 제외하고 그 이유를 적은 것 — 공유 러너의 측정은 흔들리는 테스트다.
  • 문서(런북·결정 기록·지원 표)를 후속이 아니라 차단 사유로 둔 것.
  • requireCertified 가 실패 메시지에 실제로 돈 등급을 나열하는 것.
  • 실제 전송 의존을 이 리프에만 둔 것 — 다른 리프는 전송 설정 모델만 갖는다.

Source anchors

src/grpc/grpc-testkit/build.gradle:1-66 (레인 넷 · 성능 제외 · api 리프 열)
main/java/…/testkit/netty/GrpcTlsTestMaterial.java:1-239        (§17.6 runKeytool:212-239)
main/java/…/testkit/inprocess/GrpcInProcessContractFixture.java:1-145
main/java/…/testkit/netty/GrpcNettyTestServer.java:1-137
main/java/…/testkit/netty/GrpcNettyTestClient.java:1-116
main/java/…/testkit/fault/GrpcTransportEvidenceClassifier.java:1-107
main/java/…/release/GrpcStableReleaseGate.java:1-96             (§17.2 evaluate:36-72 · §17.4)
main/java/…/performance/GrpcPerformanceGate.java:1-95
main/java/…/testkit/inprocess/GrpcInProcessTestServer.java:1-94
main/java/…/release/GrpcCompatibilityMatrix.java:1-93           (§17.4 caSkeleton:148-158)
main/java/…/testkit/netty/GrpcNettyContractProfile.java:1-87
main/java/…/testkit/inprocess/GrpcInProcessTestClient.java:1-83
main/java/…/testkit/{GrpcUnaryScenario:1-82, GrpcStreamingContractResult:1-81,
                     GrpcUnaryReliabilityContract:1-81, GrpcEvidenceGrade:1-80,
                     GrpcStreamingScenario:1-79, GrpcUnaryContractResult:1-69,
                     GrpcTextCodec:1-62, GrpcServerStreamingContract:1-61}   (§17.5)
main/java/…/performance/{GrpcPerformanceBudget:1-78, GrpcPerformanceResult:1-66}
main/java/…/testkit/fault/{GrpcFaultScenario:1-66, GrpcFaultResult:1-58, GrpcFaultPoint:1-53}
main/java/…/release/{GrpcReleaseEvidence:1-63, GrpcReleaseDecision:1-42}
test/java/…/testkit/GrpcTransportEvidenceClassifierTest.java:186-273 (§17.3)
test/java/…/ 8파일 1,339줄
src/config/gradle/ca.strict-test-lane.gradle (레인 등록 · check 미연결)

제3부 — 분석 재료

분석하는 동안 따로 두었던 목록과 기록이다. 폴더가 아니라 이 문서에 남는다.


D. 분석한 코드의 목록

분석 중에는 source-index.md 였다.

Source Index

상세 문서의 주장과 근거를 다시 찾을 수 있게 code/config/test/git/runtime source를 색인한다.

id kind path / command / URL revision or time what it proves limitations
SRC-001 git evidence/raw/001-git-snapshot.txt a24ece9cf797f7ea647e33bf846b115208ed1ba5 분석 snapshot과 clean branch 상태 이후 source 변경에는 적용 안 됨
SRC-002 registry/inventory src/config/architecture/modules.json, evidence/raw/002-module-inventory.txt same revision 44 registered leaves, dependency/runtime memberships, initial file/LOC sizing initial sizing은 각 bounded scope final denominator와 다를 수 있음
SRC-003 module policy src/domain-core/CLAUDE.md same revision domain-core responsibility/allowed/forbidden/test contract policy 문서; 실제 enforcement는 별도 확인 필요
SRC-004 rationale src/domain-core/README.md same revision ResourceId unsealed, marker/guardrail rationale 문서 claim은 code/test와 교차검증해야 함
SRC-005 code src/domain-core/src/main/java/dev/caskeleton/domain/identifier/ResourceId.java same revision self-typed ID interface와 value() surface UUIDv7 validation 구현 없음
SRC-006 code src/domain-core/src/main/java/dev/caskeleton/domain/identifier/IdFactory.java same revision identifier generation port concrete uniqueness/generation semantics는 adapter 책임
SRC-007 code src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/*.java same revision runtime-retained framework-neutral markers marker 자체가 invariant를 구현하지 않음
SRC-008 build src/domain-core/build.gradle same revision explicit module dependency block empty convention plugin/tool dependencies는 root/build-logic에서 올 수 있음
SRC-009 build policy src/build-logic/.../ModuleRegistry.groovy, ca.architecture-registry.settings.gradle same revision registry fail-closed validation과 project inclusion runtime behavior 자체는 아님
SRC-010 build verification src/build.gradle:1401+ verifyCleanArchitectureDependencies same revision actual project dependency edge와 allowlist 비교 non-project external dependencies는 이 task 범위 밖
SRC-011 architecture test src/app-bootstrap/.../CleanArchitectureTest.java domain rules same revision purity/value-object/aggregate/event/ResourceId enforcement 전체 2,792라인 중 관련 rule만 bounded scope에서 해석
SRC-012 consumer code src/sample-portfolio/.../WorkLogId.java, WorkLogIdFactory.java, UuidWorkLogIdFactory.java same revision ResourceId/IdFactory concrete reachability sample implementation이지 모든 consumer를 대표하지 않음
SRC-013 consumer code src/sample-portfolio/.../WorkLog.java, WorkLogReserved.java same revision AggregateRoot/DomainEvent production use sample domain 전체 분석은 아님
SRC-014 test src/sample-portfolio/.../WorkLogIdPropertyTest.java same revision canonical shape를 허용/비-shape를 거부 UUID version/variant는 검증하지 않음
SRC-015 test src/sample-portfolio/.../UuidWorkLogIdFactoryTest.java same revision factory canonical output/distinctness/monotonicity global uniqueness proof 아님
SRC-016 runtime consumer src/adapter/inbound/websocket/.../LiveEventStompBroadcaster.java same revision DomainEvent RUNTIME retention의 production reflection consumer 존재 broadcaster 전체 behavior는 websocket scope 대상
EVD-001 command evidence/raw/003-domain-core-test.txt 2026-08-29 :domain-core:test build task 실행 결과 module 자체 Java test 0개
EVD-002 command evidence/raw/004-clean-architecture-test.txt 2026-08-29 cross-module architecture test가 BUILD SUCCESSFUL / exit code 0 이 실행은 현재 snapshot의 전체 ArchUnit suite 결과이며 개별 rule의 미래 변경까지 보장하지 않음
SRC-017 module policy/rationale/build src/shared-contract/CLAUDE.md, README.md, build.gradle same revision stdlib-only boundary, responsibilities, custom qualification lanes policy/rationale claims are cross-checked against code/tests
SRC-018 code/resource src/shared-contract/src/main/** same revision shared error/response/rate-limit/metrics/tracing/concurrency/opstore/activation/health contracts and messaging schema downstream adapter behavior belongs to their bounded scopes
SRC-019 tests src/shared-contract/src/test/**, src/edgeRateLimitContractTest/** same revision constructor/factory invariants, vocabulary, resource digest and provider-neutral rate-limit contract does not prove external JSON Schema validator interoperability
SRC-020 reachability production references to DomainContextPropagator and OperationalRecordStorePort same revision actual app-bootstrap/JPA/sample/GraphQL consumers exist representative reachability, not exhaustive consumer analysis
EVD-005 command evidence/raw/005-shared-contract-test.txt 2026-08-29 :shared-contract:test BUILD SUCCESSFUL / exit 0 focused module test only
EVD-006 command evidence/raw/006-shared-contract-edge-rate-limit-test.txt 2026-08-29 strict edgeRateLimitContractTest BUILD SUCCESSFUL / exit 0 provider-neutral contract lane only
SRC-021 code/tests src/application-core/src/main/java/dev/caskeleton/application/{usecase,command,query,capability}/**, matching src/test/** same revision framework-free use-case type split and runtime-readable capability declaration surface mandatory/coherence enforcement still requires architecture-rule trace
SRC-022 transaction code/tests src/application-core/src/main/java/dev/caskeleton/application/transaction/{TransactionMode,TransactionPort,PolicyTransactionPort,TransactionRequest}.java, TransactionPortTest.java, TransactionRequestTest.java same revision transaction mode surface, root-write abstract contract, runnable delegation, request coherence validation remaining transaction package and concrete adapter semantics not yet fully read
SRC-023 security code/tests src/application-core/src/main/java/dev/caskeleton/application/security/**, AuthorizationContractTest.java, ObjectAccessPolicyTest.java same revision framework-neutral permission/object-access contracts, invariants, transport-neutrality regression history actual web interception/translation and production policy implementation belong to consumer traces
SRC-024 transaction code/tests src/application-core/src/main/java/dev/caskeleton/application/transaction/**, matching src/test/** same revision full application transaction vocabulary, policy request/result algebra, reconciliation and retry-safety contracts framework execution semantics require adapter trace
SRC-025 adapter code/test src/adapter/outbound/persistence-jpa/.../transaction/SpringTransactionPort.java, SpringTransactionPortTest.java same revision REQUIRED/REQUIRES_NEW mapping, explicit READ_COMMITTED, root ambient rejection, commit-before-return and commit-failure behavior focused fake transaction manager; broader policy executor belongs persistence-jpa scope
SRC-026 architecture test src/app-bootstrap/.../CleanArchitectureTest.java capability/repository/transaction/auth rules same revision mandatory UseCaseCapability, direct transaction/repository coherence, mutating permission declaration, Spring Security dependency ban direct-call static analysis has documented helper/AOP blind spots
EVD-007 command ./gradlew :application-core:test 2026-08-29 application-core tests BUILD SUCCESSFUL module tests do not alone prove adapter/runtime semantics
EVD-008 command ./gradlew :app-bootstrap:test --tests dev.caskeleton.bootstrap.architecture.CleanArchitectureTest 2026-08-29 architecture test task resolved BUILD SUCCESSFUL Gradle reported task UP-TO-DATE; verifies current cached inputs/output state rather than fresh test execution
SRC-027 application reliability code/tests src/application-core/src/main/java/dev/caskeleton/application/{idempotency,inbox,outbox,operation}/**, matching src/test/** same revision owner-safe idempotency/inbox/outbox/durable-operation state, uncertainty and retry/reconciliation semantics concrete persistence/provider implementation details belong to later scopes
SRC-028 cache/coordination code/tests src/application-core/src/main/java/dev/caskeleton/application/{cache,lease,lock,outbound,observability}/**, matching src/test/** same revision cache-aside race control, bounded single-flight/bulkhead, efficiency-only lease/lock contracts, low-cardinality outbound/observation semantics actual Redis/JDBC provider failure behavior belongs to adapter scopes
SRC-029 messaging/realtime code/tests src/application-core/src/main/java/dev/caskeleton/application/{messaging,realtime}/**, matching messaging tests same revision provider/transport-neutral messaging catalog/event envelope and realtime durability/replay/presence contracts full broker/websocket runtime behavior is cross-scope
SRC-030 storage/publication code/tests src/application-core/src/main/java/dev/caskeleton/application/{storage,fileexport,filepublication}/**, matching tests same revision legacy raw-storage compatibility path versus semantic publication contracts and CSV safety rules legacy removal completion not established
SRC-031 objectstorage code/tests src/application-core/src/main/java/dev/caskeleton/application/objectstorage/**, matching tests same revision opaque identities, bounded content streaming, staged verification/publish, fencing, grants and privilege separation concrete provider implementation is later outbound objectstorage scope
SRC-032 fileserver code/tests src/application-core/src/main/java/dev/caskeleton/application/fileserver/**, matching tests same revision upload/download lifecycle, writer fencing, cleanup/recovery, quota, HTTP/range/content-safety and physical/metadata ambiguity handling concrete fileserver adapter/runtime workers are cross-scope
SRC-033 notification code/tests src/application-core/src/main/java/dev/caskeleton/application/notification/**, matching tests same revision typed public API, protected contacts, routing, dispatch uncertainty, callbacks, provider evidence, admin/operator contracts concrete adapters/provider SDK semantics are later scopes
SRC-034 reachability evidence/raw/013-application-core-reachability.txt, evidence/raw/013-application-core-reachability-command.sh 2026-08-29 external production reference files for major application packages, distinguishing active/legacy/unwired paths static references prove reachability, not that every referenced code path executes in every deployment
SRC-035 build src/application-core/build.gradle, root src/build.gradle messaging evidence/qualification configuration same revision shared-contract-only production dependency, strict messaging qualification required classes and fail-closed evidence properties build policy does not itself prove runtime behavior
SRC-036 defect trace AdminOperationStorePort.java, NotificationAdminApplicationService.java, JpaAdminOperationStore.java same revision atomic admin claim contract and adapter implementation exist, while application service still uses find-before-effect/save and never calls claim concurrency impact is statically demonstrated by control flow/history; dedicated reproducer remains backlog
EVD-009 command evidence/raw/009-application-core-test.txt 2026-08-29 fresh :application-core:test --rerun-tasks BUILD SUCCESSFUL application-core test lane does not replace provider integration tests
EVD-010 command evidence/raw/010-application-core-messaging-qualification.txt, 010a-messaging-qualification-missing-attestation.txt, 014-application-core-verification-metadata.txt 2026-08-29 strict messaging application qualification: missing attestation fails closed; exact digest rerun passes 15 tests with 0 skipped digests identify this analysis invocation, not a broader release attestation
EVD-011 command evidence/raw/011-clean-architecture-test.txt 2026-08-29 fresh CleanArchitectureTest rerun BUILD SUCCESSFUL, 98 actionable tasks executed static architecture tests retain documented indirect-call/reflection/AOP blind spots
EVD-012 command evidence/raw/012-verify-clean-architecture-dependencies.txt 2026-08-29 verifyCleanArchitectureDependencies BUILD SUCCESSFUL validates project dependency registry edges, not arbitrary runtime calls
SRC-037 module code/test/build/docs src/adapter/outbound/support/{src/**,build.gradle,README.md,CLAUDE.md} same revision complete leaf-owned surface: MDC correlation lookup, shared fail-open dependency logger, default Spring bean seam, focused test and local design claims downstream adapter behavior and full app runtime are outside the leaf denominator and require separate consumer/composition evidence
SRC-038 registry/composition/architecture src/config/architecture/modules.json, src/app-bootstrap/.../CaSkeletonApplication.java, CleanArchitectureTest.java, src/app-bootstrap/build.gradle same revision support runtime membership, broad component-scan reachability, direct app-bootstrap dependency, and outbound-peer rule allowing shared dependency into support source/ArchUnit wiring does not prove every deployment creates or exercises every consumer path
SRC-039 consumer code/tests OutboundMessagePublisher.java, FailOpenNotificationProvider.java, OutboundMessagePublisherTest.java, NotificationAdapterTest.java, OptionalAdapterBeanGatingTest.java same revision actual messaging/notification use of the shared logger, messaging diagnostic-failure isolation regression, current notification test coverage, and optional-adapter gating harness only the support-relevant consumer paths were read; messaging/notification modules are not exhaustively analyzed in this scope
SRC-040 logging/privacy contracts application-core/.../Notification.java, notification provider/client SPIs, app-bootstrap/.../LogMaskingPatterns.java, relevant app-bootstrap README logging section, docs/registries/mdc-keys.yaml same revision Notification recipient/body PII contract, unrestricted exception surfaces, current secret-masking vocabulary, and correlation-id registry ownership does not establish the exception text behavior of every external SDK or every production log backend
SRC-041 git/document history git log -S'OutboundHttpDependencyLogger', support README history same revision historical HTTP dependency logger existed and was later deleted while the support README comparison text remained history establishes repository evolution, not the motivation behind every deletion unless explicitly recorded in commit/source text
EVD-013 static probes evidence/raw/015-adapter-outbound-support-inventory.txt through 020-adapter-outbound-support-project-edge-usage.txt 2026-08-29 quantified leaf inventory plus required public-reachability, conditional-wiring, duplicate-mechanism, documentation-drift and project-edge-usage searches textual/static probes do not cover reflection, generated registration, external consumers, or runtime execution unless separately evidenced
EVD-014 runtime probe evidence/raw/021a-support-logger-pii-probe.java, 021-support-logger-pii-probe.txt 2026-08-29 current compiled FailOpenDependencyLogger renders explicit email/body markers present in an exception message into the WARN message controlled local probe proves logger behavior for supplied exception text; it does not claim a particular real provider currently emits that exact text
EVD-015 runtime probe evidence/raw/022a-notification-logger-failure-probe.java, 022-notification-logger-failure-probe.txt 2026-08-29 current compiled notification consumer calls failure observation after a successful send when success logging throws, and propagates a failure-logging RuntimeException uses a deliberately throwing SLF4J proxy rather than a real disk-full/appender outage; it isolates the control-flow contract
EVD-016 command evidence/raw/023-adapter-outbound-support-test.txt 2026-08-29 fresh :adapter:outbound:support:test --rerun-tasks execution succeeds focused leaf test does not exercise exception-message PII or throwing-logger consumer behavior
EVD-017 command evidence/raw/024-adapter-outbound-support-dependencies.txt, 025-clean-architecture-dependencies.txt 2026-08-29 current support compileClasspath has external Spring/SLF4J dependencies and registry project-edge verification succeeds dependency verification proves allowed graph shape, not semantic necessity of every consumer edge
EVD-018 command evidence/raw/026-messaging-support-consumer-test.txt, 027-notification-support-consumer-test.txt, 028-optional-adapter-bean-gating-test.txt 2026-08-29 fresh relevant messaging, notification and optional-adapter composition test classes succeed selected focused tests do not substitute for exhaustive downstream-module or full deployed-runtime testing
EVD-019 command evidence/raw/029-clean-architecture-support-rule.txt 2026-08-29 fresh CleanArchitectureTest execution succeeds with the outbound-peer/support exception rule present ArchUnit statically verifies bytecode dependency rules; it does not prove runtime activation or logging failure behavior
SRC-042 JPA API code/tests src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/**, matching src/test/**/api/** same revision complete 49-type external API vocabulary plus 6 dedicated tests: bounded operation/query identities, failure algebra, cursor/keyset contracts, transaction/retry/completion evidence implementation semantics such as actual commit classification, SQLSTATE translation and query observation are owned by later sub-scopes
SRC-043 JPA API consumers/wiring src/app-bootstrap/.../jpa/{JpaPlatformAutoConfiguration,JpaPlatformReport,JpaPlatformEndpoint,JpaPlatformRuntimeAutoConfiguration}.java, persistence JpaRetryObservation, DefaultJpaRetryPolicy, FullTransactionRetryCoordinator same revision capability report reaches actuator model, retry reason is not a current metric tag, and API transaction types feed actual retry coordinator consumer reads are API-meaning traces only; their owning implementation sub-scopes are not thereby complete
SRC-044 JPA API surface/history docs/architecture/jpa-api-surface.txt, src/build-logic/.../ca.api-surface.gradle, git history for SignedJsonCursorCodec, CapabilitySupport, JpaEntityNotFoundException same revision committed 49-type intended external API surface and history of cursor bound hardening public-surface baseline governs names, not method behavior or external adopter usage
EVD-020 static probes evidence/raw/031-persistence-jpa-module-inventory.txt through 034-persistence-jpa-api-document-surface-check.txt 2026-08-29 605-file module denominator and API public-reachability/conditional/duplicate/document-surface probes static/textual reachability excludes reflection/generated code/external adopters and can over-count same-package Javadoc references
EVD-021 runtime probe evidence/raw/035a-jpa-cursor-boundary-probe.java, 035-jpa-cursor-boundary-probe.txt 2026-08-29 current compiled cursor codec round-trips 2045 bytes but rejects its own encoded tokens at 20462048 bytes while rejecting 2049 at encode focused codec probe does not exercise HTTP transport or an application-specific cursor payload mapper
EVD-022 runtime probe evidence/raw/036a-jpa-api-bound-probe.java, 036-jpa-api-bound-probe.txt 2026-08-29 constructors accept 100k-character capability constraints/retry reason and Integer.MAX_VALUE maxAttempts records type-level bounds only; current shipped composition does not supply such capability strings and effective retry budget needs transaction trace
EVD-023 command evidence/raw/037-persistence-jpa-api-tests.txt, 038-persistence-jpa-api-surface.txt, 039-jpa-platform-capability-composition-test.txt 2026-08-29 fresh six-class API tests, public API-surface verification and app-bootstrap capability composition test all BUILD SUCCESSFUL green existing tests do not contain the 20462048 cursor round-trip boundary property
EVD-024 history evidence/raw/040-persistence-jpa-api-history.txt 2026-08-29 cursor pre-decode bounds including decodedLengthOf were introduced in commit 2f5d2fc; capability/entity-not-found types date to initial JPA platform commit commit diffs show repository evolution but do not establish unstated author intent
SRC-045 JPA transaction/failure code src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/{transaction,failure}/**, matching dedicated tests same revision complete 32-production/19-test transaction execution, retry, deadline, completion-evidence and operational-error implementation surface real database lost-ack semantics and owning vendor translation require cross-scope evidence
SRC-046 JPA transaction composition/docs src/app-bootstrap/.../jpa/{JpaPlatformRuntimeAutoConfiguration,JpaTransactionAutoConfiguration}.java, PersistenceJpaRootAutoConfiguration.java, docs/jpa/{transaction-guide,support-matrix,runbooks}.md same revision canonical PolicyTransactionPort implementation differs from public JPA executor/coordinator stack; Stable completion-evidence docs can be compared to actual bean construction static composition does not itself reproduce a network-level unknown commit
EVD-025 JPA transaction static/runtime verification evidence/raw/041-* through 051-* 2026-08-29 51-file denominator/reachability/history, custom-policy probe, commit-ambiguity probe, fresh focused transaction tests and app-bootstrap wiring tests fake transaction managers isolate control flow; they do not prove a real PostgreSQL commit was applied before acknowledgement loss
SRC-047 Hibernate/Spring Data/Querydsl code/tests src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/{hibernate,springdata,querydsl}/**, matching dedicated tests same revision complete 42-production/11-test query/batch/stream/fetch/keyset/sort/Querydsl bounded sub-scope PostgreSQL vendor internals and entire integration source set remain later owning scopes
SRC-048 JPA query architecture/composition JpaModuleBoundaryTest.java, app-bootstrap CleanArchitectureTest.java, persistence build.gradle/gradle.lockfile, NamedStatementInspector.java same revision package DAG/export rules, Querydsl compile-only/runtime optionality, split export allowlists and missing statement-inspector production wiring static reachability does not exclude reflection/external adopters and architecture rules do not prove runtime feature invocation
SRC-049 JPA collection-fetch release evidence src/config/jpa/release-registry.json, persistence build.gradle, HibernateCollectionFetchPaginationContractTest.java, PostgreSqlQueryPlanContractTest.java, root verifyJpaReleaseGateTasks same revision blocking collection-fetch gate maps to a query-plan task selecting a different tag/test class, while target test itself does not inspect generated SQL limit aggregate release task also runs the jpa-contract lane, so this proves per-gate provenance/assertion weakness rather than absence of all collection tests
EVD-026 focused/architecture verification evidence/raw/052-persistence-jpa-hibernate-springdata-querydsl-tests.txt, 055-persistence-jpa-query-architecture-tests.txt 2026-08-29 fresh 53-file-scope focused tests and package/bootstrap architecture tests BUILD SUCCESSFUL existing tests omit the newly probed IDENTITY/Specification boundaries and cannot detect split-SSOT semantics
EVD-027 runtime/library probes evidence/raw/053-jpa-query-hibernate-boundary-probe.txt, 061-spring-data-specification-unrestricted-contract.txt 2026-08-29 property-access IDENTITY bypass, one-batch semantic edge, non-null null-predicate Specification acceptance, and Spring Data 4.0.7 unrestricted contract controlled probes do not show a current production entity/repository actually uses the failing mappings
EVD-028 PostgreSQL/release-gate verification evidence/raw/057-persistence-jpa-query-hibernate-postgresql-contracts.txt, 058-persistence-jpa-queryplan-gate-task.txt, 059-verify-jpa-release-gate-tasks.txt 2026-08-29 selected real-PostgreSQL batch/ID/collection contracts pass; exact registry task executes only PostgreSqlQueryPlanContractTest; task-existence validator still passes green collection-fetch tests prove current assertions only, not SQL-side pagination
EVD-029 reachability/history/provenance evidence/raw/054-persistence-jpa-query-hibernate-reachability.txt, 056-persistence-jpa-collection-fetch-gate-provenance.txt, 060-persistence-jpa-query-hibernate-history.txt 2026-08-29 implementation adoption/wiring, release tag mismatch, and historical fixes/initial evidence design are recoverable from raw source/history git history establishes evolution, not unstated intent

| SRC-050 | PostgreSQL vendor code/tests/migrations | src/adapter/outbound/persistence-jpa/src/{main/java,main/resources,test/java}/**/postgresql/** | same revision | 55 production Java + 9 dedicated unit Java + 9 vendor migrations의 complete owning surface와 SQLSTATE/idempotency/inbox/outbox/native/COPY/type helper 구현 | complete PostgreSQL integration/readiness source set은 sub-scope 11 소유 | | SRC-051 | PostgreSQL runtime composition | PostgreSqlPersistenceConfig, JpaPlatformRuntimeAutoConfiguration, JpaTransactionAutoConfiguration, PostgreSqlIdempotencyProviderConfig, DefaultJpaRetryPolicy | same revision | vendor translator가 transaction failure chain에, PostgreSQL Idempotency V2 store가 provider composition에 실제 연결됨 | 모든 profile/deployment가 동일 bean set을 사용한다는 보장은 아님 | | SRC-052 | reliability contracts/history | application-core/.../{idempotency,inbox,outbox}/**, docs/superpowers/specs/2026-07-28-jpa-production-capability-design.md, docs/reviews/2026-08-14-jpa-module-code-review.md | same revision/current history | replay TTL, owner-safe transition, completion-unknown/reconciliation 기대 계약과 과거 wiring findings를 현재 구현과 비교 | 문서만으로 runtime behavior를 증명하지 않으므로 probes와 교차검증 | | EVD-030 | real PostgreSQL probe | evidence/raw/062-postgresql-replay-semantic-probe.txt, 062a-postgresql-replay-semantic-probe.java | 2026-08-29 | same-store inbox forged-owner replay와 changed-retention, polling outbox changed-schedule replay를 PostgreSQL 16.15에서 재현 | 해당 inbox/outbox adapters는 현재 default production composition에서 확인되지 않은 candidate | | EVD-031 | translator/policy probes | evidence/raw/063-postgresql-40003-probe.txt, 069-postgresql-40003-policy-probe.txt | 2026-08-29 | SQLSTATE 40003이 UNKNOWN/completionUnknown=false가 되고 실제 DefaultJpaRetryPolicy에서 RECONCILE가 아니라 FAIL로 결정됨 | synthetic SQLException 40003; 실제 network commit acknowledgement loss 자체를 재현한 것은 아님 | | EVD-032 | codec probe | evidence/raw/065-pg-range-escaped-quote-probe.txt, 065a-pg-range-escaped-quote-probe.java | 2026-08-29 | current PgRangeCodec의 escaped quote/backslash endpoint self-round-trip 실패 | pure codec probe; real PostgreSQL text output/provider consumer는 실행하지 않음 | | EVD-033 | real PostgreSQL idempotency probe | evidence/raw/066-postgresql-idempotency-replay-boundary-probe.txt, 066a-postgresql-idempotency-replay-boundary-probe.java | 2026-08-29 | changed replayTtl false-same completion과 expired COMPLETED inspect/claim lifecycle divergence를 production store + PostgreSQL 16에서 재현 | temporary analysis tests; 실행 후 repository source에서 제거/복원 | | EVD-034 | command | evidence/raw/067-persistence-jpa-postgresql-unit-tests.txt | 2026-08-29 | 9 dedicated PostgreSQL unit classes fresh --rerun-tasks BUILD SUCCESSFUL | existing assertions가 새 replay/expiry/range/40003 policy boundaries를 포함하지 않음 | | EVD-035 | reachability/history | evidence/raw/064-postgresql-vendor-manifest-reachability.txt, 068-postgresql-vendor-history-review-provenance.txt | 2026-08-29 | 73-file denominator, key production construction, current history와 prior-review exact-term provenance | static reachability는 reflection/external adopter/runtime activation을 완전히 증명하지 않음 |

| SRC-053 | JPA baseline capability code/tests/migrations | evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt, baseline audit/auditing/cache/envers/h2/idempotency/liveevent/lock/migration/observation/operation/outbox/security/config sources | same revision | 61 production + 19 dedicated test + 7 migration SQL, 87/87 FULL_READ | complete PostgreSQL integration lane belongs to sub-scope 11 | | SRC-054 | baseline composition/security provenance | evidence/raw/072-baseline-capability-reachability.txt, JpaAdapterComponentsConfig, JpaPlatformRuntimeAutoConfiguration, JpaPlatformReport, docs/jpa/security.md | same revision | outbox is baseline-composed, liveevent/durable adapters are not; runtime-role Stable contract promises startup fail-fast but production never invokes requireSafe, and report only checks CREATE privilege | static caller search does not cover external applications constructing these public types themselves | | EVD-036 | real PostgreSQL probe | evidence/raw/071-liveevent-full-sweep-probe-output.txt, 071a-liveevent-full-sweep-probe.java | 2026-08-29 | after a stream is fully swept, current-row max(position) becomes null and allocator reuses position 1 on PostgreSQL 16 | temporary analysis test; adapter is currently not default-composed | | EVD-037 | real PostgreSQL probe | evidence/raw/073-durable-operation-expired-lease-output.txt, 073a-durable-operation-expired-lease-probe.java | 2026-08-29 | expired lease owner can still update RUNNING operation to SUCCEEDED before takeover because terminal update lacks lease-expiry predicate | temporary analysis test; adapter is currently not default-composed | | EVD-038 | focused adapter probe | evidence/raw/075-outbox-stale-worker-state-regression-output.txt, 075a-outbox-stale-worker-state-regression-probe.java | 2026-08-29 | baseline outbox markFailed can regress a PUBLISHED entity to FAILED with no owner/attempt/state CAS fencing | focused unit-level transition probe; multi-worker database timing itself is inferred from separately committed claim/relay transaction structure | | EVD-039 | command | evidence/raw/076-persistence-jpa-baseline-unit-tests.txt | 2026-08-29 | fresh full :adapter:outbound:persistence-jpa:test --rerun-tasks BUILD SUCCESSFUL, 18 actionable tasks executed | unit lane does not replace real-PostgreSQL semantics, which are separately probed where required |

| SRC-055 | Fileserver persistence + migrations | evidence/raw/077-persistence-jpa-fileserver-manifest.txt, src/adapter/outbound/persistence-jpa/src/main/{java,resources}/**/fileserver/** | same revision | 25 production Java + 4 migration SQL, 29/29 FULL_READ; capability is opt-in but production-composed when enabled | PostgreSQL integration/readiness source denominator remains sub-scope 11 | | SRC-056 | Fileserver quota/composition/schema contracts | evidence/raw/079-fileserver-reachability-quota-schema-contract.txt, application Fileserver quota/admission code, Fileserver schema activation, design deviations/plan | same revision | production quota aggregate readers/byte ceiling are absent; V3/V4 schema changes are not represented in registry revision while activation accepts >=2 | static cross-scope search cannot prove behavior of external adopters outside this repository | | EVD-040 | real PostgreSQL probe | evidence/raw/078-fileserver-quota-boundary-probe-output.txt, 078a-fileserver-quota-boundary-probe.java | 2026-08-29 | expired direct reservation still commits; reclaim request spanning 65 one-byte committed rows leaves 1 byte because only 64 rows are loaded | temporary analysis tests added to Fileserver reclamation class and restored after run | | EVD-041 | real PostgreSQL concurrency probe | evidence/raw/080-fileserver-recovery-concurrent-enqueue-output.txt, 080a-fileserver-recovery-concurrent-enqueue-probe.java | 2026-08-29 | two simultaneous first enqueue calls for one file produce one success + one DataIntegrityViolationException, with one row retained | scheduling is barrier-assisted but exact winner is nondeterministic as expected | | EVD-042 | real PostgreSQL migration probe | evidence/raw/081-fileserver-schema-activation-v2-output.txt, 081a-fileserver-schema-activation-v2-probe.java | 2026-08-29 | Fileserver Flyway target 2 promoted ACTIVE passes FileserverSchemaActivation while V3 claim_token and V4 lifecycle_state columns are absent | demonstrates gate false-positive; full Spring context with ddl-auto=none was not separately booted | | EVD-043 | command | evidence/raw/082-fileserver-official-readiness-lanes.txt | 2026-08-29 | original-source Fileserver migration/metadata/reclamation PostgreSQL readiness tasks fresh --rerun-tasks BUILD SUCCESSFUL, 21 actionable tasks executed, git clean before/after | these official assertions do not include the temporary boundary probes, which are captured separately | | EVD-044 | real PostgreSQL liveness probe | evidence/raw/079-fileserver-cleanup-crash-budget-output.txt, 079a-fileserver-cleanup-crash-budget-probe.java | 2026-08-29 | Fileserver cleanup의 declared max attempt 8을 넘겨 crash-reclaim만으로 attempt 9가 되고도 row가 FAILED / CLAIM_LEASE_EXPIRED로 다시 claim 가능함을 재현 | controlled repeated lease-expiry simulation; physical storage crash 자체를 발생시킨 것은 아니며 probe 종료 후 analysis-owned test source는 exact HEAD blob으로 복구됨 |

| SRC-057 | Notification persistence + migrations | evidence/raw/083-persistence-jpa-notification-manifest.txt, src/adapter/outbound/persistence-jpa/src/main/{java,resources}/**/notification/** + 5 dedicated unit tests | same revision | 53 production Java + 10 migrations + 5 unit Java, 68/68 FULL_READ; request/recipient/attempt/admin/reconciliation/provider-event/inbox persistence and opt-in composition | full PostgreSQL integration/readiness denominator remains sub-scope 11 | | SRC-058 | Notification runtime reachability/contracts | evidence/raw/092-notification-reachability-test-gap.txt, NotificationDispatchService, DispatchOutcomeRecorder, ReconciliationJobWorker, NotificationAdminApplicationService | same revision | post-provider recorder uses lease-unaware save, reconciliation processing is outside durable claim, admin atomic claim has no production caller, V4 is final registry revision write | static call graph is repository-internal and does not cover external adopters | | EVD-045 | real PostgreSQL schema probe | evidence/raw/084-notification-schema-v4-activation-probe.txt | 2026-08-29 | V4-only ACTIVE database passes activation count while current-required expires/projection/collapse/admin/evidence columns are absent | full Spring context boot was not required to demonstrate gate predicate false-positive | | EVD-046 | real PostgreSQL lease probes | evidence/raw/085-notification-expired-lease-write-probe.txt, 091-notification-stale-provider-overwrite-probe.txt | 2026-08-29 | expired holder is not still-held yet owner+fence update succeeds; after worker B takeover an ID-only stale projection overwrites lifecycle while preserving B/fence 8 | SQL reproduces exact predicates/effect; provider network call itself is not simulated | | EVD-047 | real PostgreSQL reconciliation probe | evidence/raw/086-notification-reconciliation-claim-probe.txt | 2026-08-29 | two sequential autocommit FOR UPDATE SKIP LOCKED claims return the same due job before any complete/reschedule, proving select-lock lifetime does not cover worker processing | controlled two-session semantics; provider status query itself is not executed | | EVD-048 | admin claim/reachability probe | evidence/raw/087-notification-admin-claim-completion-probe.txt, 088-notification-admin-claim-reachability.txt | 2026-08-29 | atomic claim row cannot be completed by current save-style duplicate insert, and production operations.claim() call sites are zero while find→action→save remains | direct SQL + static repository call graph | | EVD-049 | fresh official Notification verification | evidence/raw/089-notification-schema-official-readiness.txt, 090-notification-existing-contracts-fresh.txt | 2026-08-29 | schema readiness and existing recipient/admin claim contracts both BUILD SUCCESSFUL with --rerun-tasks, 19/19 tasks each | green tests validate existing assertions; they omit current-schema compatibility, post-provider lease write, reconciliation claim lifetime, and production admin claim wiring | | SRC-059 | Experimental platform code/tests/migration | evidence/raw/093-persistence-jpa-experimental-manifest.txt, src/adapter/outbound/persistence-jpa/src/main/java/**/experimental/**, dedicated unit tests, db/experimental-rls/V1__tenant_rls.sql | same revision | 29 production + 8 unit + 1 SQL의 38/38 owning FULL_READ와 multi-tenancy/RLS/schema/database tenancy/read-replica/next-compatibility surface | 현재 app-bootstrap production wiring은 확인되지 않아 semantic defects는 latent로 분류 | | SRC-060 | Experimental composition/consent | evidence/raw/096-experimental-gate-reachability.txt, 099-experimental-structural-optin-gap.txt, ExperimentalEntryConsentTest, PersistenceJpaConfig, JpaModuleBoundaryTest | same revision | 일부 activation-capable public constructor가 gate list 밖에 있고 Stable entity/repository scan 문자열이 experimental package를 포함하지만 현재 experimental JPA stereotype은 0 | external consumers/reflection까지 증명하지 않으며 현재 repository production caller는 0 | | EVD-050 | focused semantic probes | evidence/raw/095-experimental-pool-overshoot-probe.txt, 097-experimental-replica-provider-probe.txt | 2026-08-29 | heterogeneous pool 8+5가 max 10을 넘어 13으로 열림; no-evidence EVENTUAL read가 REPLICA; unknown Hibernate 9가 Stable replacement 허용됨 | analysis-only temporary unit tests; source는 실행 후 복원 | | EVD-051 | real PostgreSQL security probe | evidence/raw/098-experimental-rls-missing-table-probe.txt | 2026-08-29 | PostgreSQL 16에서 RLS verifier가 requested missing tenant table을 exception 없이 성공으로 인정 | current app runtime does not compose RLS verifier; demonstrates verifier contract only | | EVD-052 | fresh original Experimental verification | evidence/raw/100-experimental-original-unit-tests.txt, 101-experimental-original-postgresql-contracts.txt | 2026-08-29 | original 8 experimental unit + boundary/scan tests and 5 PostgreSQL contract/migration/security classes all BUILD SUCCESSFUL; git clean before/after | existing green assertions omit the newly probed missing-table, partial pool headroom, no-evidence EVENTUAL, unknown-major and exhaustive opt-in boundaries | | SRC-061 | JPA testkit + fixture/unit corpus | evidence/raw/102-persistence-jpa-testkit-manifest.txt, src/adapter/outbound/persistence-jpa/src/testkit/java/**, src/adapter/outbound/persistence-jpa/src/test/java/**/testkit/** | same revision | 41 testkit + 21 fixture/unit Java, 62/62 FULL_READ; architecture/query-plan/release/migration/failure/pool helper semantics | production persistence behavior 자체가 아니라 evidence producer의 정확성을 분석 | | EVD-053 | testkit semantic false-negative probes | evidence/raw/103-testkit-unit-boundary-probes.txt | 2026-08-29 | CompletableFuture<Entity> architecture leak accepted; audit decoy parameter accepted; nested child estimate 1000x miss가 ratio 1.0으로 축약; UUID test fixture same-ms inversion 3784에서 재현 | UUID helper는 exact FQN consumer 0이라 production defect로 승격하지 않음 | | EVD-054 | real PostgreSQL EXPLAIN side-effect probe | evidence/raw/104-testkit-explain-dml-cte-probe.txt | 2026-08-29 | SELECT-only guard가 data-modifying CTE를 허용해 EXPLAIN ANALYZE 후 row bucket 9 -> 99; PostgreSQL 16 BUILD SUCCESSFUL | current committed query-plan inputs는 SELECT이므로 current release mutation을 주장하지 않음 | | EVD-055 | testkit reachability sweep | evidence/raw/105-testkit-public-reachability-sweep.txt | 2026-08-29 | production/release-connected helpers와 zero-reference CommitAmbiguityProxy, PostgreSqlContractExtension 구분 | simple-name collision은 exact FQN 추가 확인으로 보정 | | EVD-056 | fresh original testkit evidence | evidence/raw/106-testkit-original-verification.txt | 2026-08-29 | JPA full unit 29s green, production JPA architecture 1m47s green, original PostgreSQL query-plan 25s green; git clean before/after | green suite가 newly probed wrapper/CTE/child-plan/substring boundaries를 포함하지 않음 |

| SRC-062 | governance / build / config | evidence/raw/107-persistence-jpa-governance-manifest.txt, adapter/outbound/persistence-jpa/{CLAUDE.md,README.md,build.gradle,gradle.lockfile}, config/**, JpaModuleBoundaryTest, CandidateAdapterCompositionTest | same revision | leaf 최상위 4 + production 4 + test 3 = 11/11 FULL_READ; strictTestLanes/testkitPublisher/apiSurface 계약, 24-package catalog와 export list, always-install 대 opt-in scan 경계 | sub-scope 06이 이미 계상한 config/platform test 3개는 여기서 재계상하지 않으며, 그 test의 내용 분석만 이 sub-scope가 수행 | | SRC-063 | composition reachability | evidence/raw/108-governance-config-reachability.txt, PersistenceJpaRootAutoConfiguration, CaSkeletonApplication, PostgreSqlPersistenceConfig, H2PersistenceConfig, PersistenceVendorProdSafetyValidator | same revision | config public type 3개의 leaf 밖 소비자 존재; leaf @ConfigurationProperties 3형제 중 PersistenceVendorSettings만 production enablement 부재; composition root @ConfigurationPropertiesScan이 persistence tree를 덮지 않음 | 정적 검색은 reflection/외부 adopter를 덮지 않는다; prod safety validator는 h2 값만 거부하므로 unknown 값 검증과 무관 | | EVD-057 | 실행 probe | evidence/raw/109-vendor-selector-shipped-shape-probe.txt, 109a-vendor-selector-shipped-shape-probe.java | 2026-08-29 | shipped 모양 + vendor=mysql에서 context 성공·settings bean 0·SPI bean 0·양 vendor config 0; @EnableConfigurationProperties 추가 시 property를 지목하는 ConfigurationPropertiesBindException; component scan 포함 case의 실패 메시지에 vendor property 부재 | ApplicationContextRunner context이지 full application context가 아니므로 실제 배포에서 최초로 실패하는 bean은 다를 수 있음; 임시 test는 실행 후 삭제하고 git clean 확인 | | SRC-064 | documentation / count drift | evidence/raw/110-governance-doc-count-drift.txt, src/build.gradle:verifyDocumentedLeafCount, docs/jpa/repository-adaptation.md, docs/architecture/jpa-api-surface.txt, JpaModuleBoundaryTest, CleanArchitectureTest, leaf README.md | same revision | registry 44 대 문서/주석의 19-leaf 6곳; public top-level type 338/350 대 주석의 318/324; README의 package root·module 이름 drift; gate 탐색 domain이 CLAUDE.md/build.gradle 두 파일명뿐 | drift는 문서·주석에 한정되며 런타임 동작에 영향 없음; gate 자체는 green으로 통과함을 별도 실행으로 확인 | | SRC-065 | pool lane | evidence/raw/111-persistence-jpa-pool-lane-manifest.txt, 112-pool-lane-claim-registry-reachability.txt, src/jpaPlatformPerformanceTest/**, config/jpa/release-registry.json, docs/jpa/support-matrix.md, .github/workflows/jpa-nightly.yml | same revision | 3/3 FULL_READ; release registry gate 6개에 pool lane 부재(3곳 모두 exit=1)이나 jpaPlatformReleaseGate는 의존; lane assertion 8개 중 2개가 공식 항등식이고 pending 관측 assertion 부재 | verifyJpaReleaseGateTasks는 registry→task 한 방향만 검사하므로 역방향 미검증 사실은 정적 관찰 | | EVD-058 | 실제 PostgreSQL probe | evidence/raw/113-pool-lane-saturation-probe.txt, 113a-pool-lane-saturation-probe.java | 2026-08-29 | 실제 포화 Hikari pool에서 active=2/idle=0/pending=1/saturated=true, 반납 후 대기자 획득; acquisition 실측 504ms 대 설정 500ms 대 assertion 상한 2,500ms | 단일 머신 관측이며 성능 수치가 아니라 계약 관측; 임시 test는 실행 후 삭제하고 git clean 확인 | | EVD-059 | fresh original verification | evidence/raw/114-governance-pool-original-verification.txt | 2026-08-29 | 원본 소스에서 leaf unit lane 28s green(18 executed), jpaPlatformPoolContractTest 24s green(18 executed), root verifyDocumentedLeafCount 3s green(9 executed), git clean before/after | green은 기존 assertion의 통과를 뜻하며 §119·§125가 지적한 미검증 영역을 포함하지 않는다 | | SRC-066 | integration/readiness lane corpus | evidence/raw/120-persistence-jpa-integration-lane-manifest.txt, src/postgresqlIntegrationTest/** | same revision | 75/75 FULL_READ(71 Java + 4 SQL, 13,977 lines); tag 5종 분포, 무-tag 클래스 13개와 readiness task 14개의 1:1 대응, orphan test 0 | 파일 소유는 이 sub-scope이나 §52(collection-fetch)·sub-scope 10(explain runner) 등 이미 판정된 결함은 중복 계상하지 않음 | | SRC-067 | evidence 배선(card ↔ lane ↔ gate) | config/jpa/readiness-cards.yaml, src/gradle/jpa-evidence.gradle, config/jpa/release-registry.json, .github/workflows/{ci-quality-gates,jpa-nightly,jpa-pr,jpa-release,jpa-r2-evidence}.yml | same revision | card 17개 중 active 13, producer 12; card scenario가 가리키는 클래스 12개는 전부 readiness/**이고 platform/** 33개는 0개; readiness task는 jpaPlatformReleaseGate에 없고 workflow가 직접 부르지도 않음 | 배선은 --dry-run task graph resolve로 확인했으며, CI 실제 실행 이력은 이 저장소 안에서 확인할 수 없다 | | EVD-060 | command | evidence/raw/115-integration-lane-original-verification.txt | 2026-08-29 | 원본 소스에서 5개 tag lane --rerun-tasks BUILD SUCCESSFUL in 3m 10s — 51 classes / 244 tests / 0 skipped / 0 failures / PostgreSQL 컨테이너 87회 기동, git clean before/after | 같은 실행에 이어 붙인 verifyJpaCandidateEvidence:app-bootstrap:test의 compose scenario가 jq 부재로 실패해 완주하지 못했다(환경 제약, app-bootstrap 소유) | | EVD-061 | command | evidence/raw/116-readiness-lane-original-verification.txt, 118-readiness-task-result-matrix.txt | 2026-08-29 | 14개 readiness task --rerun-tasks --continue 결과 표: 82 tests / 0 skipped / 12 PASS / 2 FAIL, git clean before/after | postgresqlSecurityBaselineIntegrationTest의 실패는 인증서 SAN이 localhost 단일이라 형제 컨테이너 환경에서 매핑 포트에 닿지 못한 환경 제약이며 repository 결함이 아니다 | | EVD-062 | 실패 재현 + history | evidence/raw/117-flyway-migration-readiness-lane-failure.txt | 2026-08-29 | postgresqlMigrationIntegrationTest 단독 --rerun-tasks BUILD FAILED/exit 1, 두 assertion 실패 원문(1,3,4,5,6,9,10,11,121,3,4,5,6; 1,21), stream 파일 목록, assertion 최종 수정 2026-07-31 대 migration 추가 08-15/08-18/08-28 | 원본 unmodified source, git clean before/after; CI가 현재 red인지 여부는 저장소 안에서 확인할 수 없고 확인된 것은 이 revision에서 task가 실패한다는 사실이다 | | EVD-063 | 실제 PostgreSQL probe | evidence/raw/119-notification-migration-ladder-probe.txt, 119a-notification-migration-ladder-probe.java | 2026-08-29 | notification 사다리 4종의 컬럼 집합 실측: V1V10 218, V1V9 218(차 0), V1V8 213(차 5), V1V6 208(차 10)과 누락 컬럼 이름 전체 | 컬럼 집합 비교이며 데이터·제약 semantics 비교가 아니다; V10이 DDL 없는 guard이기 때문에 V9와 V10의 컬럼 수가 같다 | | SRC-068 | cross-scope 정합 | HibernateCollectionFetchPaginationContractTest, PostgreSqlQueryPlanContractTest, PostgreSqlSecurityContractTest, TenantPoolCapacityContractTest, AdminOperationClaimContractTest | same revision | 앞선 sub-scope 04·06·08·09·10의 결함이 이 lane에서 green으로 남은 이유를 각 test의 assertion 범위로 설명(SQL limit 미확인, CTE 미포함, test가 만든 role/policy, 균일 pool 크기, test가 재작성한 claim SQL) | 정합 설명이며 새 결함 판정이 아니다 |

| SRC-069 | module map / denominator | evidence/raw/121-persistence-mongo-module-inventory.txt, src/adapter/outbound/persistence-mongo/** | same revision | mongo leaf 497 tracked files(main 353 / test 104 / testkit 35 / perf 1 / top-level 4), 약 38.5k LOC, production Java 351, package 지도, 선언된 8개 test lane | 초기 sizing이며 각 sub-scope의 최종 denominator는 그 sub-scope manifest가 확정한다 | | SRC-070 | governance / opt-in scope | evidence/raw/122-mongo-governance-optin-manifest.txt, mongo CLAUDE.md/README.md/build.gradle/gradle.lockfile, root package 4, autoconfigure/** 9, META-INF 등록 resource 2, 관련 test 12 | same revision | 31/31 FULL_READ; opt-in 네 겹(import filter → root auto-config → persistence config → platform/observability auto-config)과 각 겹이 막는 실패, startup validator/probe requirement 배치 | 이 sub-scope의 도달성은 Java import graph가 아니라 등록 metadata와 annotation 인자에 있어 정적 참조 검색만으로 판단할 수 없다 | | SRC-071 | opt-in reachability / sibling / duplicate | evidence/raw/123-mongo-optin-reachability-and-siblings.txt, CapabilityDependencyValidator, CapabilityDependencyStartupCheck, application.yml:370 | same revision | root/autoconfigure public type 13개 중 leaf 밖 참조는 2개; master switch를 읽는 production 지점 6곳이 조건 동일; ca-skeleton.persistence-mongo.* namespace의 네 번째 key active-profile만 소유자가 leaf 밖 | 정적 검색은 reflection·생성 등록·저장소 밖 adopter를 덮지 않는다 | | EVD-064 | 실행 probe | evidence/raw/124-mongo-platform-settings-binding-probe.txt, 124a-mongo-platform-settings-binding-probe.java | 2026-08-29 | change-streams=true → 예외 없이 bound value false; transactions=true → true 보존; required-secondaries=-1MongoOperationRejectedException으로 context 실패 | binding 계층만 관측했고 change stream 실행체 자체는 이 저장소에 shipped되지 않는다; 임시 test는 실행 후 삭제하고 git clean 확인 | | SRC-072 | documentation / count drift | evidence/raw/125-mongo-governance-doc-count-drift.txt, mongo build.gradle, docs/architecture/mongo-api-surface.txt, MongoNamespaceContractTest, MongoModuleBoundaryTest, docs/mongodb/repository-adaptation.md, docs/adr/ADR-MONGO-001 | same revision | build.gradle 주석의 311/313 대 실측 346/351, 382 대 실측 526; namespace guard의 domain(src/main/**)과 그 밖에 생존한 폐기 키 3+2곳; 19-leaf claim 3곳 대 registry 44; README "10개 규칙"은 방향 규칙 개수로 정확 | drift는 주석·문서에 한정되며 런타임 동작에 영향 없음; 관련 gate는 모두 green임을 별도 실행으로 확인 | | EVD-065 | fresh original verification | evidence/raw/126-mongo-hermetic-lane-original-verification.txt | 2026-08-29 | 원본 소스 --rerun-tasks: unit lane 14 classes/72 tests, stable contract lane 83 classes/526 tests, 둘 다 0 skipped/0 failures; verifyMongoTestLaneDisjointness·verifyMongoReleaseContractLanes·verifyMongoApiSurface 통과; :app-bootstrap:test --tests *CapabilityDependencyValidatorTest* 통과; git clean before/after | hermetic lane만 실행했고 Docker 기반 6개 lane(replicaSet/failover/migration/compatibility/security/performance)은 이 실행에 포함되지 않는다 |

| SRC-073 | api core contract | evidence/raw/127-mongo-api-scope-manifest.txt, adapter/outbound/persistence-mongo/src/main/java/**/api/** + 전용 test 9 | same revision | 70/70 FULL_READ(61 production 2,687 LOC + 9 test); operation identity·실행 context·실패 algebra·BSON 표현 manifest·consistency registry·schema version 정책·관측 seam; committed public surface 346 중 api 기여 59 | api는 의도된 외부 표면이라 저장소 내부 참조 0이 dead를 뜻하지 않으며, 저장소 밖 adopter는 정적 검색으로 증명되지 않는다 | | SRC-074 | api negative-space probes | evidence/raw/128-mongo-api-negative-space-probes.txt, MongoFailureContext, MongoTransaction*Exception, MongoSchemaVersionPolicy, DefaultMongoFailureTranslator, MongoConsistencyDescriptor, Database/CollectionProfileName | same revision | framework-free 규칙 소스 전수 검색 매치 0; schema-version 예외 두 생성 경로의 category/버전 비대칭; MongoTimeoutException 2-arg 생성자의 initCause와 그 규칙을 검사하는 test의 대상 불일치; invariant를 강제하는 타입과 강제하지 않는 타입 6종 비교; 두 profile-name record의 검증 코드 동일성 | 정적 판정이며 실행 probe를 필요로 하지 않았다; production 경로의 정합성(classification 파생)은 코드 경로 추적으로 확인했고 런타임 실행으로 재확인하지는 않았다 |

| EVD-066 | 실행 probe | evidence/raw/129-mongo-empty-type-registry-write-probe.txt, 129a-...java | 2026-08-29 | 빈 type registry로 MappingMongoConverter.write(...)IllegalStateException: no type metadata policy is registered for …; Spring 기본 mapper는 같은 입력에서 _class 포함 3키를 씀; policyForCLASS_METADATA_ALLOWED, writeTypeRestrictions_class를 쓰고 writeType은 던진다 | 단일 converter 경로만 관측했고 repository·template 상위 경로 전체를 실행하지 않았다; 임시 test는 실행 후 삭제하고 git clean 확인 | | SRC-075 | mapping / nativecap / geo | evidence/raw/130-mongo-mapping-nativecap-geo-manifest-and-probes.txt | same revision | 27/27 FULL_READ; BSON 표현 manifest의 converter 등록 경로, native capability gateway의 노출 규칙, geospatial operations의 sphere-index 요구; reachability·조건부 형제·중복 mechanism probe | 정적 판정 중심이며 geo 경로는 실제 2dsphere index를 가진 서버에서 재확인하지 않았다 | | SRC-076 | imperative / reactive 실행 경로 | evidence/raw/131-mongo-execution-paths-manifest-and-probes.txt | same revision | 61/61 FULL_READ; 두 executor의 관측·실패번역·deadline 처리와 collection profile registry 경유 규칙; deadline 미부착 지점과 실행 scope 이탈 경로 식별 | 두 executor의 배선은 auto-configuration 정적 판독으로 확인했고 런타임 컨텍스트로 재확인하지 않았다 | | SRC-077 | query / aggregation | evidence/raw/132-mongo-query-aggregation-manifest-and-probes.txt | same revision | 29/29 FULL_READ; allowlist 기반 query 표현 불가능성, keyset cursor HMAC·타입태그·길이 프레이밍; 배선된 bean은 MongoBudgetEnforcer 하나이고 그 소비자도 미배선; aggregation executor가 collection을 String으로 받아 registry·실행 scope를 우회 | 두 finding 모두 정적으로 결정 가능해 실행 probe를 돌리지 않았다; 미배선이므로 현재 노출은 없다 | | SRC-078 | transaction / retry / session | evidence/raw/133-mongo-transaction-manifest-and-probes.txt | same revision | 27/27 FULL_READ; body 재시도와 commit 재시도의 두 루프 분리를 blocking·reactive 양쪽에서 코드로 추적; transaction subsystem bean 0·cross-package 참조 0인데 platform.transactions=true는 startup에서 TRANSACTION capability를 요구 | 미배선 판정은 이 leaf 안의 정적 검색 결과이며 저장소 밖 adopter는 덮지 않는다 | | SRC-079 | schema / migration | evidence/raw/134-mongo-schema-migration-manifest-and-probes.txt | same revision | 58/58 FULL_READ; manifest 기반 선언과 D4 apply 사다리, migration lease의 fencing token 설계; apply policy·diff engine·TTL validator의 production 소비자 0; mongoMigrationTest lane 1 class/8 tests/0 failures | apply policy 계층의 미배선은 이 leaf 범위의 검색 결과다; ledger의 ensureIndexes 전제는 운영 배포 시점에만 확인 가능하다 | | EVD-067 | 실행 probe | evidence/raw/134a-mongo-schema-migration-execution-probes.txt | 2026-08-30 | 실서버(MongoDB 8.0 replica set): saveCheckpoint(fence=1 over stored 5) REFUSED / recordApplied(fence=1 over stored 5) ACCEPTED → 밀려난 runner가 ledger를 차지하고 live runner는 driver duplicate-key를 받음; hermetic: TTL·sparse·partial·collation 변경과 서버-숨김 index가 전부 diff.isClean()=true; MongoIndexManifest.expireAfter(1s) 생성 성공; Flamingock lease로 non-resumable migration도 REFUSED | ledger 경로만 직접 호출했고 MongoMigrationRunner.applyOne의 인접 lock.refresh 보호는 별도로 코드로 확인했다; 임시 test 2개는 실행 후 삭제하고 git clean 확인 | | SRC-080 | changestream | evidence/raw/135-mongo-changestream-manifest-and-probes.txt | same revision | 26/26 FULL_READ; source bean은 무조건·consumer bean은 fork의 5종 SPI에 조건부로 실제 조립됨; changeStreams flag는 false 고정이라 startup CHANGE_STREAM 검사 도달 불가; recovery의 네 결정 메서드 중 하나만 사용되고 MongoChangeHistoryLostException은 어디에서도 생성되지 않음; 세 기존 test가 "본 적 있지만 완료되지 않은 위치"를 다루지 않는 이유 | SPI 구현은 전부 test fixture이며 실제 fork 배선을 관측한 것은 아니다 | | EVD-068 | 실행 probe | evidence/raw/135a-mongo-changestream-execution-probes.txt | 2026-08-30 | auto-configuration과 동일 조립으로 3종: (C) worker 1개·failover 중 투영 취소 → 재전달 이벤트가 pipeline에 삼켜지고 checkpoint가 그것을 지나침, state=RUNNING·runbook 빈칸·Flux 정상 완료; (A) BUSY+resumable 실패에서 동일 소실; (B) 실패 없이도 후속 이벤트가 CLAIMED_ELSEWHERE 위치를 지나침; (D) requireCorrectResumeOption은 자기 자신과 비교 | reactor 기반 hermetic 재현이며 실제 서버의 change stream 재전달 순서를 관측한 것은 아니다(재전달 대상은 stored checkpoint 기준으로 구성); 임시 test 2개는 실행 후 삭제하고 git clean 확인 | | SRC-081 | security / failure / observation / client | evidence/raw/136-mongo-security-failure-observation-client-probes.txt | same revision | 44/44 FULL_READ; failure는 완전 배선(classifier·translator bean, 두 executor가 사용), security 검증 경로는 MongoStartupValidator가 실행, observation은 driver 리스너만 customizer로 설치, client는 저장소 전체 호출자 0; allowlist 강제·redactor describe·잔여 API 4종의 production 호출 0 | 저장소 밖 adopter가 MongoClientSettingsFactory를 쓰는 경우는 정적 검색으로 배제할 수 없다 | | EVD-069 | 실행 probe | evidence/raw/136a-mongo-client-settings-execution-probe.txt | 2026-08-30 | MongoSecurityProfile.production(tls=true, auth=true)를 validator가 ACCEPTED; Boot가 README의 spring.data.mongodb.uri에서 만드는 설정은 sslEnabled=false, connect 10000ms, serverSelection 30000ms, poolMaxSize 100, serverApi=null, uuidRepresentation=UNSPECIFIED; factory가 만들었다면 sslEnabled=true | 실제 Spring 컨텍스트가 아니라 Boot가 사용하는 동일 API로 재구성한 비교다; 임시 test는 실행 후 삭제하고 git clean 확인 | | SRC-082 | advanced 전체 | evidence/raw/137-mongo-advanced-manifest-and-probes.txt | same revision | 75/75 FULL_READ; guard bean은 auto-load되지 않는 MongoAdvancedConfiguration에만 있어 기본 배선에서 모든 entry point 도달 불가(설계와 일치); 구체 클래스 19개 = entry point 7 + policy 11 + 의도적 제외 1(@Configuration); 실행 probe: 모든 승인 증거를 갖춘 입력에서 sharding 4작업 중 1개만 실행(3개는 5인자 executeapproval=null로 거부); promotion 증거 어휘 2종·checkpoint 작성자 2곳·Scaffold 고지 누락 1건 | 임시 probe test는 실행 후 삭제하고 git clean 확인; Advanced 능력의 실제 동작은 해당 토폴로지가 없어 검증 불가 | | SRC-083 | testkit / release / lanes | evidence/raw/138-mongo-testkit-release-lanes-probes.txt | same revision | 49/49 FULL_READ; testkit 33종 소비자 계수에서 MongoRoundTripContract·MongoAtlasLocalContainer·MongoChunkMigrationController 0; MongoStableContractSuite(not executed) 분기와 certified() 커버리지 검사가 구조적으로 도달 불가(형제 MongoChaosGate는 정상); release-contracts.json의 차단 계약 3개가 전부 topology=none hermetic이고 experimental 3개는 등록되지 않은 task를 가리킴; .github/workflows 26개 중 mongo 언급 0(JPA는 7개) | CI 부재는 이 저장소의 workflow 파일 기준이며 외부 파이프라인은 관측 범위 밖이다 |

| SRC-084 | module map / denominator | evidence/raw/139-identifier-module-inventory.txt, src/adapter/outbound/identifier/** | same revision | leaf 전체 10 tracked files(main Java 4 / test Java 1 / test Groovy 1 / governance 4), 562 LOC; 단일 패키지 dev.caskeleton.adapter.outbound.identifier; 레지스트리 allowed_dependencies=[domain-core, application-core], runtime_memberships=[app-bootstrap, sample-portfolio]; 소비자는 app-bootstrap·sample-portfolio 2곳 | 10 파일 전수이므로 sampling 없음; 저장소 밖 adopter는 덮지 않는다 | | SRC-085 | reachability / doc drift | evidence/raw/140-identifier-negative-space-probes.txt | same revision | 세 production 타입 소비자 계수(UuidCodec 0, 나머지 둘은 composition root 배선); UuidCodec. 호출은 자기 Spock 스펙 5줄뿐이고 leaf 밖 20+ 파일이 UUID.fromString을 직접 호출, D10 대상은 Hibernate @JdbcTypeCode(SqlTypes.UUID)가 처리; CLAUDE.md의 build.gradle 선언 서술 3항목 불일치; README의 패키지 루트·Groovy variant·설정 키 3건 오류; ArchUnit IDENTIFIER_ADAPTER_DOES_NOT_DEPEND_ON_OTHER_ADAPTERS_OR_BOOTSTRAP는 실재(confirmed)하고 .claude/hooks/ca_import_gate.py는 tracked 아님 | .claude/는 로컬 설정 영역이라 개발자 머신 존재 여부는 판정 불가 — clone 기준으로만 부재를 기록했다 | | EVD-070 | 실행 probe | evidence/raw/140-identifier-negative-space-probes.txt (EXECUTION PROBE 절) | 2026-08-30 | normalize("1-1-1-1-1")"00000001-0001-0001-0001-000000000001", "1-2-3-4-5""00000001-0002-0003-0004-000000000005" (canonical 아닌 입력을 수용해 재작성), "not-a-uuid"·무대시 32자·공백 포함은 IllegalArgumentException; normalize(null)→null / toUuid(null)·fromUuid(null)→NPE; newFileId·newUploadId 모두 version=4, UUID.randomUUID() version=4 | JDK 21의 UUID.fromString 동작에 의존하는 결과이며 다른 JDK 버전에서 관대 경로가 달라질 수 있다; 임시 test class는 실행 후 삭제하고 git clean 확인 |

| SRC-086 | module map / denominator | evidence/raw/141-fileserver-module-inventory.txt, src/adapter/outbound/fileserver/** | same revision | 119 tracked files(main 78 / 12,707 LOC, test 37 / 12,043 LOC, governance 4); 패키지 배치 루트 31 · platform/local 33 · verification 10 · security 2 · audit 2; 별도 source set·test lane 없음; leaf 밖 소비자는 app-bootstrap 하나 | 초기 sizing이며 각 sub-scope의 최종 denominator는 해당 manifest가 확정한다 | | SRC-087 | config / activation | evidence/raw/142-fileserver-config-activation-probes.txt | same revision | 13/13 FULL_READ; R1/R2/플랫폼 세 namespace 분리와 rejectAmbiguous의 세 진입점 배치; 적재는 auto-configuration이 아니라 CaSkeletonApplication의 명시적 @ComponentScan(imports 파일 없음); R1은 ignoreUnknownFields 없음·상대 루트 허용·디렉터리 생성, R2는 전부 반대; README가 지목한 selector 기본값이 application.yml에 없음 | .claude·로컬 설정은 tracked 파일 기준으로만 판단했다 | | SRC-088 | control plane / codec | evidence/raw/143-fileserver-control-plane-probes.txt | same revision | 6/6 FULL_READ(main 2,535 LOC); canonical 왕복 강제(decode 후 재encode 바이트 비교), 인접 전이 행렬과 terminal 종결성, SecureDirectoryStream 17회 vs R1 저널 0회, poison 래치의 root 범위와 읽기 허용이 test 이름으로 의도임을 확인; 세 타입 모두 package-private·leaf 밖 참조 0 | 후보 finding 3건을 코드·test로 추적해 결함 아님으로 판정했고 실행 probe는 돌리지 않았다 | | SRC-089 | publication | evidence/raw/144-fileserver-publication-probes.txt | same revision | 26/26 FULL_READ; 19개 production 타입 전부 package-private·leaf 밖 참조 0; recoverR2가 상태별로 재개하고 producer를 재생하지 않음; 길이 프레이밍 canonical digest와 route token 충돌 컴파일 검사; R1/R2 엄격도 6축 대조와 두 계층 접점(decodeStoredOperation)이 엄격 경로를 쓰는지 확인 | R1/R2 격차는 문서가 선언한 상태이며 결함으로 올리지 않았다 | | SRC-090 | platform/local IO | evidence/raw/145-fileserver-local-io-probes.txt | same revision | 30/30 FULL_READ; SecureDirectoryWalk의 서술자 상대 하강과 fallback 부재; platform/localFiles.* 호출 전수 조사 → 쓰기 경로에 남은 5곳(AtomicMoveContentPublisher:53·113·114, ContentPublishVerification:53·114)과 그 유일한 보호가 모듈이 "a precheck could only ever approximate"라 적은 requireNoSymlinkBetween; TransferBufferPool.maxBorrowedBytes()의 소비자 0 | 발행 rename의 도달성은 스토리지 루트 쓰기 권한에 달려 있고, 그 루트 증명은 app-bootstrap 몫이라 이 leaf 범위에서 확정하지 않았다 | | SRC-091 | verification / security / audit | evidence/raw/146-fileserver-verification-security-audit-probes.txt | same revision | 29/29 FULL_READ; AdminAuditPort·FileserverAuditPort·FileAccessPolicy·StorageHealthPort·OrphanScanPort·StorageUsageProbe·ReconciliationContentProbe 구현 8종과 app-bootstrap의 bean 생성 8곳을 확인해 README:105를 반증; 검증 사슬의 fail-closed 합성(timeout/예외 → RETRY, RETRY > ACCEPT); 인가 거부 메시지에 역할 미포함 | bean 생성 지점은 app-bootstrap 소스 기준이며 런타임 컨텍스트로 재확인하지는 않았다 | | EVD-071 | 실행 probe | evidence/raw/146-fileserver-verification-security-audit-probes.txt (EXECUTION PROBE 절) | 2026-08-30 | inlineSafeProfile=false에서 ScriptableContentPolicy 9종 입력 판정: 평문 <script>·<html>·선행 공백·대문자·<svg>는 QUARANTINE, UTF-8 BOM + <html>·선행 HTML 주석 + <script>·NUL + <html>는 ACCEPT | String.stripLeading()의 JDK 동작에 의존하는 결과이며, 브라우저별 스니핑 차이는 검증하지 않았다; 임시 test class는 실행 후 삭제하고 git clean 확인 | | SRC-092 | payload / CSV / testkit | evidence/raw/147-fileserver-payload-testkit-probes.txt | same revision | 15/15 FULL_READ; LocalPersistentPayloadOperations가 JDK에 서술자 상대 대응물이 없는 세 원시연산(createLink·createDirectory·force)을 javadoc에 선언하고 identity 검사로 감싼 것을 호출 전수로 확인; CSV 인코더의 세 상한과 수식 정책 3종; CrashRecoveryMatrixTest@EnumSource(CrashPoint.class)로 전 크래시 지점을 순회하고 두 발행 전략이 같은 ContentStoreContract를 상속 | testkit의 NFS·PVC fixture는 해당 환경이 없어 실행하지 않았다 |

| SRC-093 | module map / denominator | evidence/raw/149-objectstorage-module-inventory.txt, src/adapter/outbound/objectstorage/** | same revision | 206 tracked files(main 147 / 14,336 LOC, test 48 + resource 1 / 6,753 LOC, qualification source set 3종 6 files / 546 LOC, governance 4); 패키지 배치 s3 26 · control 24 · kernel 23 · config 19 · direct 13 · readiness 8 · maintenance 8 · codec 7 · filesystem 6 · multipart 5 · provider 4 · 루트 4; AWS SDK v2 BOM을 모듈 범위로 import; 레지스트리 allowed_dependencies=[application-core, shared-contract], runtime_memberships=[sample-portfolio] | 초기 sizing이며 각 sub-scope의 최종 denominator는 해당 manifest가 확정한다 | | SRC-094 | governance / config / routing | evidence/raw/150-objectstorage-config-activation-probes.txt | same revision | 28/28 FULL_READ; app.object-storage는 기본 비활성이고 legacy alias와 혼용 시 예외; ObjectStorageProviderContribution이 describe(부작용 없음)와 create(부분 할당 정리 책임)를 분리; filesystem-local-dev가 production 프로파일에서 거부되나 판정이 prod/production 두 리터럴 소문자 비교; RoutingObjectReadAdapter의 무방비 split("\\.",-1)[1]ObjectReference 생성자 검증이 막아 결함 아님 | .claude·로컬 설정은 tracked 파일 기준으로만 판단했다 | | SRC-095 | control plane / canonical JSON | evidence/raw/151-objectstorage-control-probes.txt | same revision | 25/25 FULL_READ(main 2,470 LOC); canonical 강제가 reader·writer·봉투 세 겹; 레코드 불변식이 값이 아니라 상태-증거 관계를 검증; 열거 API 부재로 키 공간 스캔이 표현 불가; 관용 UTF-8 디코딩 후보를 printable ASCII 검사로 추적해 결함 아님으로 판정 | zero-finding 결과이며 실행 probe는 돌리지 않았다 | | SRC-096 | kernel / codec | evidence/raw/152-objectstorage-kernel-codec-probes.txt | same revision | 39/39 FULL_READ; 닫힌 상태 전이 행렬과 terminal 종결성, epoch fencing, pending effect / 응답 유실 화해; markEffectSent·markResponseLostcurrent.updatedAt()을 그대로 넘겨 의도 시각과 유실 시각이 durable 레코드에 남지 않음 | 정적으로 결정 가능한 지점이라 실행 probe 불필요 | | SRC-097 | s3 provider | evidence/raw/153-objectstorage-s3-probes.txt | same revision | 40/40 FULL_READ(main 3,581 LOC); AWS SDK 참조가 s3/** 16 + legacy 3에 갇히고 leaf 밖 production 유출 0(밖의 hit는 ArchUnit 금지 타입 카탈로그와 빌드 파일뿐); S3ClientPolicy가 재시도 최악 예산 ≤ 부모 호출 예산을 강제하고 평문 AWS 엔드포인트를 거부하며 자격증명을 [REDACTED]로; S3ProviderBinding이 AWS/MinIO에 반대 방향 신원 규칙을 강제하고 autoCreateBucket·publicAcl을 금지; S3ProviderErrorMapper가 mutation 불확실성을 INDETERMINATE, authoritative=false로 보존 | zero-finding 결과; 실제 AWS/MinIO 호출은 하지 않았고 정책·매핑의 정적 판정만 수행했다 | | SRC-098 | direct transfer / multipart | evidence/raw/154-objectstorage-direct-multipart-probes.txt | same revision | 25/25 FULL_READ; requirePartSize의 세 호출 지점 중 DirectMultipartCoordinator:163finalPart=false 하드코딩이고 PartUploadGrantRequest에 마지막 part 표시 없음; validateSignedGrant가 upload 경로 1곳에만 있고 expectedExpiry는 미검사, planningEndpoint()는 정의상 항상 통과; coordinator·policy의 패키지 밖 참조 0이고 README가 "no direct-grant port is registered"로 선언하나 AWS binding은 DIRECT_* capability 주장을 통과시켜 presigner를 할당(MinIO는 거부) | 미배선 상태라 실행 probe 불가; 세 finding 모두 호출 인자·호출 부재·조립 경로로 정적 확정했다 | | SRC-099 | filesystem / maintenance / readiness / provider / 루트 | evidence/raw/155-objectstorage-platform-readiness-probes.txt | same revision | 43/43 FULL_READ(main 30 / 2,420 LOC); readiness 레지스트리 forward reference 해소 — docs/registries/object-storage-readiness.yaml는 저장소 루트에 실재하고 build.gradle이 시스템 프로퍼티로 전달, leaf test가 9장 카드 집합·R1 두 장·R0 여섯 장을 강제; legacy-adoption.enabled=true, mode=APPLY는 설정으로 켜지는데 Ed25519LegacyAdoptionApprovalVerifier 생성 지점이 저장소 전체 0이고 승인자 키 설정 항목도 없음; ClaimResult가 발행을 막지 않음; filesystem-local-dev capability 표 2벌; legacy 루트에 production 프로파일 검사 없음 | 승인 검증기의 부재는 tracked 소스 기준이며, fork가 자체 bean을 공급하는 경우는 관측 범위 밖이다 | | SRC-100 | qualification lanes | evidence/raw/156-objectstorage-qualification-lanes-probes.txt | same revision | 6/6 FULL_READ; 세 lane 모두 strict·non-skipping·requiredClasses 고정; 여섯 파일 중 provider를 실제로 호출하는 것은 MinioManagedObjectContractTest(digest 고정 MinIO에서 If-None-Match: * PUT과 CompleteMultipartUpload가 기존 객체를 덮고 stale If-Match만 412)와 MinioManagedObjectFaultTest(Toxiproxy 절단 → 5초 이내 유계 실패와 복구) 둘뿐; AWS lane 2종은 환경변수만 검사; provider 신원 문자열과 이미지 digest가 enum·version map·evidence JSON 세 곳에 독립 기재되고 교차 검사 없음 | lane 실행에는 Docker와 보호된 AWS sandbox 권한이 필요해 이 분석에서 실행하지 않았고, lane이 주장하는 내용과 그 소비처만 정적으로 추적했다 | | EVD-072 | suite 검증 | evidence/raw/157-objectstorage-suite-verification.txt | 2026-08-30 | :adapter:outbound:objectstorage:test → classes=47 tests=140 failures=0 errors=0 skipped=0; git status --short 변경 0 | qualification source set 3종은 :test에 포함되지 않으며 Docker·AWS sandbox 권한이 필요해 실행하지 않았다 | | SRC-101 | module map / denominator | evidence/raw/158-cache-redis-module-inventory.txt, src/adapter/outbound/cache-redis/** | same revision | 390 tracked files(main 316 / Java 314 · 32,082 LOC + resource 2, test 70 / Java 69 · 15,244 LOC + golden 1, governance 4); 자체 Redis SDK를 품은 leaf로 설계 문서의 다중 모듈 배치를 패키지 경계로 대체하고 RedisSdkModuleBoundaryTest가 강제; build.gradle이 spring-data-redis·micrometer 부재를 근거와 함께 선언하고 redisTopologyTest lane 4종에 mode allowlist·필수 클래스·최소 test 수·skip 0 게이트를 건다 | 초기 sizing이며 각 sub-scope의 최종 denominator는 해당 manifest가 확정한다 | | SRC-102 | governance / config / 조립 | evidence/raw/159-cache-redis-config-activation-probes.txt | same revision | 26/26 FULL_READ; app.redis.enabled 하나가 전체 스위치이고 RedisSdkSettings@ConfigurationPropertiesScan 밖이라 "contributes nothing"이 문자 그대로 성립(test 4종); auto-configuration이 @Bean 7개(settings·validation·credentials·client·runtime owner·redisOptional·redisRequired)를 만들어 README의 "조립되지 않는다" 서술 중 셋을 반증; RedisStartupProbe/RedisCapabilityProbe의 production 호출자 0; raw allowlist 기본 리소스 부재는 startup 실패로 닫혀 있어 결함 아님 | 승인자 키·capability 설정은 tracked 소스 기준이며 fork의 자체 bean 공급은 관측 범위 밖 | | SRC-103 | 타입 있는 명령 표면 | evidence/raw/160-cache-redis-api-surface-probes.txt | same revision | 83/83 FULL_READ; Expiration이 sealed이고 Persistent가 permit을 요구해 TTL 없는 쓰기가 표현 불가; permit/budget 요구를 4종으로 분류; "설계상 부재" 6건(KEYS·SETNX/SETEX/PSETEX·ZREVRANGE·RPOPLPUSH/BRPOPLPUSH·GEORADIUS·members())을 API·Lettuce 구현·명령 정책 yml 세 계층에서 대조(KEYS는 R4 BLOCKED); ApiParityTest가 sync/reactive 대칭을 반사로 강제하고 검사기 자신의 메타 test 보유; RedisOperations·ReactiveRedisOperations 구현 0 | 개별 표면 43종은 전부 구현돼 있으며 facade만 없다 | | SRC-104 | 키 · 실패 · 명령 기술 · reactive | evidence/raw/161-cache-redis-key-error-command-probes.txt | same revision | 64/64 FULL_READ; 렌더된 키를 받는 API 부재를 sealed 계층과 renderer 단일 지점으로 확인; RedisFailureMetadata의 "모호 실행은 retryable 불가" 불변식과 storedDataCorruption 팩토리의 분리(실호출 2곳); RedisCommandDescriptor의 교차 필드 불변식 4종; requireIdentifier의 메일·전화 분기가 선행 문자 클래스에 가려 도달 불가이고 test는 타입만 검사; reactive getRange 누락 의심을 직접 diff로 오탐 판정 | 예외의 transient metadata는 직렬화 왕복 시 null이며 문서에 없다 | | SRC-105 | gateway · request builder | evidence/raw/162-cache-redis-gateway-probes.txt | same revision | 95/95 FULL_READ(main 10,718 LOC); RedisCommandGateway가 114개 명령별 메서드로만 이루어지고 명령 이름을 받는 메서드 0; 11개 계열 전부 sync/reactive가 같은 *OperationRequests를 생성; RedisOperationContext가 R1 permit 검증·scan overshoot 허용(+512)·BLMOVE 이중 permit 처리를 한 곳에 모음; 응답 예산 강제 12곳; 구독 경로만 guard를 지나지 않고 그 대체 검사(네임스페이스·패턴 permit)가 실재; permit 정책 이름이 Java 18 + YAML 18 + 별도 상수 1로 흩어짐 | in-memory double은 implements로 114 메서드가 강제되므로 누락 의심은 오탐 | | SRC-106 | guard · 연결 · 코덱 · 관측 | evidence/raw/163-cache-redis-guard-connection-codec-probes.txt | same revision | 44/44 FULL_READ; guard의 고정 순서 11단계와 두 건의 수정 이력(죽은 validateReply 제거, 발화 불가였던 다중 키 절 분리); 정책 문서를 일반 YAML 엔진으로 읽지 않는 이유와 그 좁은 파서; 레인이 계정을 함께 유도(SCRIPT→ADVANCED); RedisRuntimeOwner의 OPEN→DRAINING→CLOSED와 포화 시 거부; 두 실행자가 같은 협력자 4종; RedisCommandMetadataDiff("The build gate")의 main 참조 0 · lane 참조 0 · Gradle 태스크 0; 정책 문서 필드 표 11 vs loader allowlist 12 | drift gate의 비교 로직과 test 6종은 완성돼 있고 없는 것은 실제 서버 메타데이터를 넣는 연결이다 | | SRC-107 | 스크립트 · 트랜잭션 · 확장 · raw · 클러스터 | evidence/raw/164-cache-redis-programmability-extensions-probes.txt | same revision | 54/54 FULL_READ; raw gateway가 카탈로그 RAW_ONLY 분류와 배포 승인 두 문을 모두 요구하고 토큰이 발급 레지스트리 인스턴스까지 검증; RawMovableKeysSORT/SORT_RO 한 형태만 파싱하고 BY/GET은 패턴이라 의도적으로 거부(test 8종); 스크립트 등록이 배포 단계이고 README의 EVALSHA→NOSCRIPT→SCRIPT LOAD 사슬이 forget(...) 호출로 실재; 그러나 NOSCRIPT 판정이 5벌이고 4벌이 레지스트리를 우회; 슬롯 검사 2곳은 범위가 달라 중복 아님 | 진입 타입 5종(raw gateway·transaction runner·admin·cluster observer/cursor)의 production 참조 0 | | SRC-108 | 의미 포트 어댑터 | evidence/raw/165-cache-redis-semantic-adapters-probes.txt | same revision | 24/24 FULL_READ; CacheRegionPort·IdempotencyStorePortV2·DistributedLeasePort·EdgeRateLimitPort·ConnectionRegistryPort·EphemeralFanoutPort 6종 3,295 LOC 구현 확인(전용 test 5 + LiveRedisSemanticPortsTest)으로 README의 "그 코드는 이 leaf에 없다"를 반증; 포트별 실패 정책 대비(cache만 degrade, rate limit은 fail-closed, idempotency는 INDETERMINATE); 다섯 어댑터가 guard·executor·타입 API 참조 0이고 lease.gateway()를 직접 호출(30곳); CapabilityKeyspacerequireRenderedSize를 적용하지 않는 두 번째 키 렌더 경로 | 현재 미배선이므로 즉각 노출은 없고, 키는 같은 RedisNamespace에서 조립되므로 네임스페이스 봉쇄만은 구성으로 유지된다 | | EVD-073 | suite 검증 | evidence/raw/166-cache-redis-suite-verification.txt | 2026-08-30 | :adapter:outbound:cache-redis:test → classes=52 tests=435 failures=0 errors=0 skipped=0; git status --short 변경 0 | redisTopologyTest lane 4종은 excludeTags로 제외되며 실제 서버와 -Predis.topology.*를 요구해 실행하지 않았다 | | SRC-109 | module map / denominator | evidence/raw/167-httpclient-module-inventory.txt, src/adapter/outbound/httpclient/** | same revision | 370 tracked files(main 260 / 15,004 LOC, test 62 / 6,049, testkit source set 35 / 2,754, httpClientPerformanceTest 7 / 495, jmh 2 / 130, governance 4); 설계의 19-모듈 배치를 패키지 경계로 대체하고 HttpClientModuleBoundaryTest가 강제; HTTP/3가 compileOnly인 이유·Jackson 3가 필수인 이유·testkit이 별도 source set인 이유·lane 둘이 failOnNoDiscoveredTests를 잃었던 기록이 build.gradle 주석에 있음; check에 hermetic lane 4종이 붙음 | 초기 sizing이며 각 sub-scope의 최종 denominator는 해당 manifest가 확정한다 | | SRC-110 | profile / startup 검증 | evidence/raw/168-httpclient-profile-probes.txt | same revision | 35/35 FULL_READ; ClientProfileValidator34종 위반 코드를 결정적 정렬로 내고 각 코드가 막는 다운그레이드를 주석으로 적음(바인딩되지만 전송에 닿지 않는 설정 3종을 무시가 아니라 거부); app-bootstrap HttpClientStartupValidator:37이 실제 배선; ClientRuntimeRegistry.close()throw firstFailure를 스케줄러 종료보다 먼저 실행; POOL_ROUTE_EXCEEDS_TOTALPoolSettings 생성자에 가려 도달 불가; 34종 중 test가 이름으로 잡는 것은 12종 | 위반 코드별 test 참조 계수는 test/testkit source set 전체 기준 | | SRC-111 | 공개 API 어휘 | evidence/raw/169-httpclient-api-probes.txt | same revision | 55/55 FULL_READ; 증거 3축(ExecutionEvidence·AttemptStage.provesNotSent·BodyReplayability.weakest)과 OperationIdempotency가 D-09를 구성; HttpFailureMetadata가 제외 목록을 열거하고 HttpOperation·ObjectBody·IdempotencyKey가 각각 과거 유출을 근거로 toString을 재정의; TRACE 부재를 test가 반사로 확인; ObjectBody.deeplyImmutableinstanceof NumberAtomicInteger류 가변 타입을 통과시킴 | api/body 7종 중 값을 담는 둘만 toString 재정의가 필요함을 전수 확인 | | SRC-112 | 재시도 자격 · 가드 파이프라인 | evidence/raw/170-httpclient-resilience-probes.txt | same revision | 47/47 FULL_READ(test 47 메서드); 결정표가 절대 차단 6 → 영구 → 증거 → 상태/실패 순으로 단조; 408/425/429가 멱등성 검사를 건너뛰던 수정 이력; AttemptResiliencePipeline이 회로 permission을 얻은 뒤 rate/bulkhead 거부 경로에서 반환하지 않음(releasePermission 저장소 전체 매치 0, bulkhead 경로는 rate 토큰만 반환); PARTIAL_RESPONSE 분기 도달 불가 의심을 전송 분류기 4종의 생산 지점으로 오탐 판정 | Resilience4j HALF_OPEN 시험 슬롯 소진은 라이브러리 의미론에 근거한 추론이며 실행 재현은 하지 않았다 | | SRC-113 | 게이트웨이 · 응답 경계 | evidence/raw/171-httpclient-gateway-probes.txt | same revision | 46/46 FULL_READ; 와이어/디코드 두 예산과 읽는 도중 강제; RemoteProblemDecoder가 원격 status를 폐기하고 와이어 상태를 신뢰; BlockingRedirectCoordinator가 hop마다 allowlist 재적용·cross-origin 자격증명 제거·303 본문 폐기; BoundedDataBufferFluxdoOnCancel·onErrorResume가 no-op; 리다이렉트 hop 상한은 RedirectEvaluator:16이 강제(오탐 판정) | 버퍼 누수를 주장하지 않았다 — no-op 연산자와 javadoc의 불일치만 기록 | | SRC-114 | 보안 · 자격증명 | evidence/raw/172-httpclient-security-auth-probes.txt | same revision | 46/46 FULL_READ; 절대 URI를 정화가 아니라 거부; 멱등성 키가 실제로 헤더로 나가지 않던 수정("A duplicated payment is the shape of that bug"); HeaderPolicy의 소유 헤더 9종과 CR/LF 거부; SensitiveHeaderStripper의 "adds rather than replaces" 수정; auth record 5종 중 값을 담는 셋 모두 redacted toString; zero-finding | 가짜 메타데이터 편의 생성자는 production 호출자 0으로 확인 | | SRC-115 | 동적 대상 · 관측 · 서비스 | evidence/raw/173-httpclient-service-dynamic-probes.txt | same revision | 56/56 FULL_READ; SSRF 사슬(정규화 → 전체 응답 검증 → thread-local 핀 → 전송 resolver 주입)을 호출 지점으로 전수 추적, app-bootstrap HttpClientTransportAutoConfiguration:81CallScopedDnsPin::addressesFor를 주입; HttpClientTagPolicy가 모르는 태그를 거부하고 쿼리 값은 통째로 폐기; 계약 lane의 메타 test 3종; ValidatedDnsResolver.approved 무경계 의심을 finallyforget과 production 호출자 0으로 오탐 판정 | zero-finding | | SRC-116 | 전송 6종 · testkit / perf / jmh | evidence/raw/174-httpclient-transport-testkit-probes.txt | same revision | 85/85 FULL_READ; TransportCapabilityValidator가 프로파일 요구와 전송 선언을 대조해 startup 거부; validatedDnsPinning 검사가 블로킹 오버로드에만 있고 리액티브에는 없음 — 주석이 "the two were being conflated"라고 지적한 상태가 한쪽에 잔존(현재 두 리액티브 전송은 두 플래그가 같아 노출 없음); testkit 계약 클래스 6종을 세 lane이 공유; 성능 lane 7종은 풀·재시도·회전·토큰경합·본문·H2를 재고 본문 재생 가능성 판정 비용은 재지 않음 | HTTP/3는 compileOnlyHttp3CapabilityReport가 클래스 부재 시 startup에서 거부 | | EVD-074 | suite 검증 + 실패 진단 | evidence/raw/175-httpclient-suite-verification.txt | 2026-08-30 | :checksrc/gradle/archive-hygiene.gradle 게이트가 build/libs의 이전 리비전 JAR 2개를 발견해 실패(소스 무관, remedy 태스크 cleanStaleTraceableJars 존재, 이 분석은 삭제하지 않음); lane 5종 개별 실행 → test 283/3 failed, 나머지 4 lane 42 tests 0 failed, skipped 0; 실패 3건은 MutualTlsHandshakeContractTestexpected: TLS_HANDSHAKE but was: CONNECTApacheFailureClassifier가 원인 사슬을 바깥부터 훑는데 CONNECT 분기가 TLS 분기보다 앞서 HttpHostConnectException이 안쪽 SSLHandshakeException을 가림; git status --short 변경 0 | hermetic test이고 의존성이 잠겨 있어 결정적으로 재현된다 | | SRC-117 | module map / denominator | evidence/raw/176-outbound-messaging-module-inventory.txt | same revision | 69 tracked files(main 46 / Java 34 · 4,246 LOC + resource 12, test 19 / Java 16 · 3,670 LOC + resource 3, governance 4); build.gradle이 YAML·Jackson 2를 전 configuration에서 제외하고 verifyJsonSchemaRuntimeGraphcheck에 붙임; qualification lane 2종이 필수 클래스 목록과 함께 등록되고 루트 :prepareMessagingContractEvidence에 의존 | 초기 sizing | | SRC-118 | 활성화 · 조립 | evidence/raw/177-outbound-messaging-activation-probes.txt | same revision | 14/14 FULL_READ; app.messaging.enabled(스위치)와 app.messaging.broker(선택자)의 분리와 그 이유; MessagingOffAutoConfigurationImportFilterspring.factories에 실제 등록되어 Boot의 Kafka/AMQP 자동설정 5종을 차단; 실행 probe: verifyJsonSchemaRuntimeGraph FAILED — 요구 좌표 tools.jackson.core:jackson-core:3.0.2 vs 잠긴 3.1.5; CompiledMessagingDescriptor의 leaf-main·app-bootstrap 참조 0이며 계약·목적지·봉투·스키마 4패키지를 leaf 밖에서 참조하는 파일 0 | 컴파일된 절반의 목적(증거 산출)은 build.gradle의 lane 구성에서 추론했고 문서에는 없다 | | SRC-119 | 닫힌 JSON Schema 레지스트리 · 봉투 | evidence/raw/178-outbound-messaging-envelope-probes.txt | same revision | 21/21 FULL_READ; 어휘 allowlist 8종·닫힌 키워드 부분집합 5종($anchor·$dynamic*·$recursive*)·참조 사전 검사·핀 고정 메타스키마 9종; 실행 probe: authority.sha256의 9개 해시가 동봉 파일의 실제 SHA-256과 전부 일치; 봉투 작성이 원시 JSON 파서/생성기 API 없이 스냅샷 바이트를 삽입하고 가변 접근자를 한 번만 호출; 적대적 test 29 메서드가 파서·수·컬렉션·해시 경계를 이름으로 고정 | zero-finding | | SRC-120 | 계약 컴파일 · 목적지 · 파티션 키 | evidence/raw/179-outbound-messaging-contract-destination-probes.txt | same revision | 13/13 FULL_READ; 열린 타입(raw·wildcard·Map·JSON 트리·인터페이스·제네릭 record 그래프)을 페이로드로 거부하고 기여 접근자를 정확히 한 번만 호출; 도메인 분리 상수 7종이 전부 버전 붙은 형태이고 4바이트 길이 프레이밍을 공유; 카탈로그 다이제스트가 입력 순서 독립; PartitionKeyV1이 교차 언어 벡터 진입점을 제공하고 골든 벡터 2종(비ASCII UTF-8 바이트 길이 포함) 보유 | zero-finding | | EVD-075 | suite 검증 | evidence/raw/180-outbound-messaging-suite-verification.txt | 2026-08-30 | :adapter:outbound:messaging:test → classes=17 tests=92 failures=0 errors=0 skipped=0; :checkverifyJsonSchemaRuntimeGraph에서 실패(게이트 자신의 버전 하드코딩); git status --short 변경 0 | qualification lane 2종은 루트 증거 파이프라인에 의존해 실행하지 않았다 | | SRC-121 | 바인딩 컴파일 · 템플릿 카탈로그 | evidence/raw/183-notification-catalog-dispatch-probes.txt | same revision | 23/23 FULL_READ; digest 헬퍼가 문자열을 4바이트 길이 프레이밍으로 먹여 인접 필드 충돌을 구조적으로 배제; LocalEmailRenderer의 컨텍스트 인지 이스케이프 사슬(태그 27종 허용목록 → 토큰 문법이 컨텍스트를 요구 → 삽입 위치 검증 → 치환 시점 컨텍스트별 이스케이프 → quoteReplacement → 미해결 {{ 거부)을 전수 확인; 템플릿 자산은 manifest 체크섬 + 계획에 얼어붙은 체크섬·렌더러 리비전 이중 대조; validateRouteShapeSINGLE 전용 가드(:141)가 switch(:159)보다 앞서 FAN_OUT_ALL·ORDERED_FALLBACK·default와 순환 탐지기 전체가 도달 불가이고 legacyReceiptUnsafeFallbackBoundsAndCyclesAreRejectedhasMessageContaining("strategy")로 다른 가드에 걸려 통과 | 순환 탐지기는 정적으로 도달 불가를 증명했고 실행으로 반증하지는 않았다 | | SRC-122 | 스케줄러 · 런타임 세대 · 시도 permit | evidence/raw/183-notification-catalog-dispatch-probes.txt | same revision | 30/30 FULL_READ; 실행 probe: 배경 작업자 3종(LeaseRecoveryService·ProviderEventReplayWorker·ReconciliationJobWorker)이 app-bootstrap NotificationPlatformWorkerConfig에 빈으로 등록되고 NotificationBackgroundWorkers.start()가 각각 scheduleWithFixedDelay — 배선 확인됨(cache-redis RedisStartupProbe 호출자 0과 대조); 상태 전이 7종을 형제로 비교해 markDraining/markDisabledcurrent를 받고 쓰지 않음을 확인; AUTHENTICATION_FAILED → markDraining() → resumeHealthy()RegistryProviderRuntimeControl:30-39를 통해 관리자에게 노출되어 javadoc이 선언한 거부를 우회하고 원인 코드를 파괴; ProviderRuntimeStateTest:125-140이 세 전이를 각각 새 객체에서만 확인 | 우회는 정적 증명(무조건 덮어쓰기 3줄)이며 실행 재현은 하지 않았다 | | SRC-123 | 렌더링 이스케이프 · 저장 시 암호화 | evidence/raw/184-notification-template-security-probes.txt | same revision | 32/32 FULL_READ; SecretPurpose 8종이 소유자 1:1이고 NotificationSecretRequirements가 전부 열거; AesGcmContactPointProtector가 두 키를 요구하고 keyId·자료 양쪽으로 동일성 거부, AAD에 접촉점 종류 결속, 키 256비트 강제; .auditRequired() 0건 · .purposeCode() 0건 — "Every reveal is auditable"를 선언한 AccessContext의 필드를 읽는 코드가 저장소에 없고 reveal은 null 검사만 함(감사 싱크 LoggingNotificationAudit은 존재하며 ProviderRuntimeRotator가 사용); Thymeleaf process( 2곳 중 catch:92 하나 — 프로덕션이 타는 mode-aware 오버로드(:80)는 무방비이고 CanonicalNotificationRenderer:125,133이 모드 있는 쪽만 호출 | 메시지 유출의 실제 문자열은 Thymeleaf 구현에 달려 있어 실행으로 확인하지 않았다 | | SRC-124 | provider 포트 · SPI 라우팅 · 관측 | evidence/raw/185-notification-provider-core-probes.txt | same revision | 38/38 FULL_READ(main 29 + test 9, probe가 카운트 확인); Retry-After 힌트가 NotificationDispatchService:381RetryBackoff:44-45로 도달하고 계산값보다 길 때만 채택된 뒤 max로 상한 — javadoc 주장과 일치, 회로 닫힘; RoutingNotifier가 채널 내 providerId 중복과 미등록 라우트를 생성자에서 거부하고 런타임 미바인딩은 AdapterDisabledException; 첨부 무결성이 AttachmentIntegrityGuard.resolve 안에서 크기·다이제스트를 MessageDigest.isEqual로 대조; NotificationHealthReporter가 unhealthy로 보는 4조건에 DRAINING이 없어 SRC-122의 우회가 헬스 신호도 함께 끔; 음수 Retry-AfterProviderFailure 생성자에서 IllegalArgumentException | 음수 헤더는 RFC 비준수 provider를 전제하며 실행 재현하지 않았다 | | SRC-125 | provider 구현 8종 · SigV4 · SNS · RFC 8291 | evidence/raw/186-notification-provider-impl-probes.txt | same revision | 76/76 FULL_READ(main 60 + test 16, probe가 카운트 확인); requireExternallyRoutable의 프로덕션 호출처는 SES·webhook 둘뿐이고 가드 javadoc이 지목한 "Web Push endpoints and webhook targets"와 다름 — WebPushSubscriptionValue:50-57이 약한 스킴 검사의 private 사본을 들고 있어 grep으로도 드러나지 않으며 EndpointGuardCallSiteTest에 WebPush 언급 0; boundedBodyBodySubscribers.ofByteArray()를 upstream으로 써서 전부 읽은 뒤 mapping으로 자름 — 주석의 "not ofByteArray()"와 반대; SigV4가 getHost()(포트 없음)를 서명하고 JDK는 포트 포함 Host를 보냄; AwsSignatureV4Signer:117-119가 비밀을 String으로 승격(leaf 내 유일); SNS SignatureVersion 1(SHA-1)을 발신자가 선택 가능하고 v2 강제 설정 없음 | Web Push SSRF는 이 템플릿에 인바운드 등록 경로가 동봉되지 않아 현 상태로는 미도달 — 포크가 붙이는 순간 통과된 상태가 된다 | | SRC-126 | SPI 기여 2종 · 계약 testkit | evidence/raw/187-notification-spi-testkit-probes.txt | same revision | 19/19 FULL_READ(main 6 + test 9 + resources 4); ProviderFaultHarness가 목이 아닌 실제 JDK HTTP 서버 소켓을 띄워 "본문 기록 후 연결 절단"을 재현; PiiLeakScanner가 패턴이 아니라 정확한 픽스처 값을 탐색; 성능 lane이 처리량이 아니라 규모 무관 성질만 게이트; ProviderResults.fromTransport 호출 어댑터 6종에 FCM이 없고 FcmBatchCoordinatorgateway.sendBatch를 try 없이 호출 — "커밋 후 응답 손실 = ambiguous" 규칙 밖의 유일한 provider이며 배치라 한 번의 손실이 배치 크기만큼 영향; extends ProviderAdapterContract 3/8, ContractAdapters 등록 5/8, FCM 테스트에 AMBIGUOUS 0건 — 두 계약 집합이 모두 비껴간 provider가 계약을 만족하지 않는 provider | FCM 전송 실패의 실제 예외 타입은 포크가 넣는 FcmGateway 구현에 달려 있다 | | EVD-076 | suite 검증 | evidence/raw/187-notification-spi-testkit-probes.txt | 2026-08-30 | :adapter:outbound:notification:testBUILD SUCCESSFUL, GRADLE_EXIT=0; 이 모듈의 발견 7건(P2)은 모두 테스트가 통과하는 상태에서 나왔다 — 두 건(SRC-121·SRC-122)은 통과하는 테스트가 다른 이유로 통과하거나 합성을 확인하지 않은 경우 | :check는 실행하지 않았다(httpclient에서 확인된 archive-hygiene 스테일 JAR 게이트가 소스와 무관하게 실패하며, 이 분석은 build 산출물을 삭제하지 않는다) | | SRC-127 | module map / denominator / 소스셋 | evidence/raw/188-inbound-web-module-inventory.txt | same revision | 638 tracked files이 여섯 소스셋으로 갈림(main 400 · test 150 · testkit 54 · webfluxContractTest 16 · jettyCompatTest 9 · nginxProxyTest 5 · governance 4); main Java 27,473 LOC · test Java 18,319 LOC · main 패키지 74개; build.gradle이 각 소스셋 분리의 근거를 "그렇게 하지 않으면 레인이 무엇을 인증하게 되는가"로 적음(Jetty 레인이 Tomcat 위에서 돌 위험 · 리액티브 게이트가 서블릿 스택을 인증할 위험 · Docker 없는 check@Disabled로 귀결될 위험) | 초기 sizing; 12개 sub-scope 분할은 이 트리에서 기계 계산 | | SRC-128 | 패키지 도달성 지도 | evidence/raw/192-inbound-web-package-reachability.txt | same revision | main 74개 패키지 각각에 대해 (a) 다른 web main 패키지가 import하는 수 in, (b) app-bootstrap/sample-portfolio가 import하는 파일 수 ext를 기계 산출. security in=0 ext=0(11파일) · cache in=0 ext=0(4) · advanced/* 대부분 in=0 ext=0 · fileserver.*ext>0가 다수. 이 지도가 SS3SS11의 도달성 판정 기준선 | import 기반이므로 리플렉션·컴포넌트 스캔 경로는 별도 확인 필요(각 sub-scope에서 수행) | | SRC-129 | 거버넌스 · 모듈 경계 강제 | evidence/raw/189-inbound-web-governance-probes.txt | same revision | 51/51 FULL_READ; 다섯 커스텀 레인의 Gradle 참조 0, CI 워크플로 다섯 파일이 전부 호출 — 회로는 YAML에서 닫힘; WebStableModule(539줄 enum)이 모듈 id·패키지·순도·허용 edge를 선언하고 WebModuleBoundaryTest가 실제 트리를 스캔해 양방향 대조(긍정 5규칙 + 부정 픽스처 4개 + 빈 스캔 거부); 프레임워크 탐지 정규식에 Jackson 2·3 병기("a hole in exactly the check that is supposed to have none"); CorsSettingscontains("*")setAllowedOrigins 소비처와 정확히 짝 | 레인 실행은 하지 않음 | | SRC-130 | 에러 계약 이중화 | evidence/raw/190-inbound-web-error-probes.txt | same revision | 33/33 FULL_READ; @RestControllerAdvice가 같은 컨텍스트에 등록되어 다섯 프레임워크 예외에서 problem+json이, 나머지 22종에서 Envelope이 나간다(WebMvcProblemExceptionHandler @Order(HIGHEST_PRECEDENCE+10) vs 무순서 GlobalExceptionHandler); README:168이 "D5: RFC 7807 ProblemDetail 표현은 거부"를 선언하고 problem+json/RFC 9457/ProblemCode 언급 0; leaf의 어떤 테스트도 두 advice 를 함께 세우지 않으며 NoResourceFoundErrorHandlingTest는 이기는 쪽을 @Import에서 빼고 지는 쪽을 단언; WebProblemSanitizer.alreadySafe 호출자 0이고 내부 삼항도 도달 불가 | 두 advice 공존 시의 실제 응답은 정적 추론(순서 규칙)이며 실행 재현하지 않음 | | SRC-131 | 신원 · 요청 컨텍스트 배선 | evidence/raw/191-inbound-web-security-probes.txt | same revision | 44/44 FULL_READ; WebMvcRequestContextHolder.store() 호출자 저장소 전체 0이고 자동설정이 등록한 argument resolver는 require()로 항상 throw; new WebRequestContext( main 유일 지점이 WebFluxRequestContextFilter:81이며 ActorContext.anonymous()·TenantContext.none()·Locale.ENGLISH·ApiMajorVersion(1) 넷을 상수로 채움; security 패키지 11파일이 서로만 참조(WebSecurityContextBridge main_refs=0, AuthenticationView 생성은 테스트뿐), 교차 테넌트 가드 rejectTenantInput이 그 섬 안에만 존재; publicPaths permitAll이 RestrictedPathRule보다 먼저 등록됨 | 관리자 2단계 우회(§12.4의 auth-mode 철자)는 Spring @ConditionalOnPropertyequalsIgnoreCase 동작에 대한 정적 추론 | | SRC-132 | 용량 보호 계층의 등록 지점 | evidence/raw/193-inbound-web-capacity-probes.txt | same revision | 50/50 FULL_READ; WebMvcBudgetFilter·WebFluxBudgetFilter·WebMvcThrottleFilter·WebFluxThrottleFilter·SemaphoreAdmissionController 다섯 전부 픽스처 애플리케이션에서만 생성(testkit/webtestkit/BudgetFixtureApplication:43 등), main·app-bootstrap 등록 0; WebBudgetCatalog는 두 자동설정이 만들고 읽는 코드 0; backend.web.budgets 문자열이 자바 한 줄에만 존재해 예외 핸들러는 기본 꺼짐이고 켜면 BudgetProblemMapper 빈 부재로 부팅 실패; 배선된 속도 제한은 RateLimitWebConfig의 MVC 인터셉터 하나(APP_RATE_LIMIT_ENABLED:false) | 리액티브 속도 제한 부재는 인터셉터가 WebMvcConfigurer라는 사실에서 도출 | | SRC-133 | 멱등성 · durable operation 조립 | evidence/raw/194-inbound-web-idempotency-probes.txt | same revision | 50/50 FULL_READ; WebIdempotencyGate·두 invoker·IdempotentResponseWriter·SemanticRequestFingerprintFactory 전부 test/testkit에서만 생성, app-bootstrap의 web 멱등성 참조 0(그쪽 bootstrap/idempotency/*는 애플리케이션 계층 PostgreSQL 제공자); 두 durable-operation 컨트롤러가 app.web-platform.durable-operations.enabled로 게이트되나 그 문자열이 yaml에 없고 OperationQueryService 빈도 없음; 의미 지문이 U+001F 구분자 기반이고 길이 프레이밍이 아님 | 지문 충돌은 구성 가능성만 확인, 실제 충돌 생성은 하지 않음 | | SRC-134 | 표현 계층(페이지네이션·조건부·캐시·버전) | evidence/raw/195-inbound-web-representation-probes.txt | same revision | 54/54 FULL_READ; 다섯 패키지 중 소비 모듈이 실제로 부르는 것은 ETags 하나(sample-portfolio WorkLogController 3곳); filter/CacheControlFilter(@Component, 24줄)가 모든 응답에 Cache-Control: no-store를 붙여 같은 컨트롤러의 ETag/If-None-Match 304 경로를 규격상 무력화하고, 그것을 조정하도록 설계된 cache 패키지 310 LOC은 패키지 밖 참조 0; UnsupportedApiVersionException은 main throw 지점 0 | no-store와 조건부 읽기의 충돌은 RFC 9111 해석에 근거하며 브라우저 실동작은 확인하지 않음 | | SRC-135 | JSON 강건화 · 코덱 · OpenAPI | evidence/raw/196-inbound-web-codec-probes.txt | same revision | 45/45 FULL_READ; WebJsonProfile 8개 필드 중 7개는 BoundedJsonFactory/WebObjectMapperFactory가 강제하고 maxArrayElements만 독자 0 — Jackson 3 StreamReadConstraints에 배열 원소 상한이 없고 매퍼도 검사하지 않음, §16.1의 바이트 예산 부재와 겹쳐 배열 원소 수 상한이 어느 계층에도 없음; maxStringBytes가 Jackson의 문자 기준 maxStringLength에 매핑; SecureXmlInputFactory가 DTD·외부 엔티티를 끄고 거부 리졸버까지 검(secure() 자가검사 포함); 배선된 OpenApiCustomizerconfig/OpenApiContractConfig의 익명 람다 하나이고 openapi 패키지 607 LOC은 빈이 되지 않음 | XML/CBOR은 compileOnly로 런타임 부재가 의도된 설계 | | SRC-136 | 필터 체인 · 관측 · 프록시 신뢰 | evidence/raw/197-inbound-web-observability-probes.txt | same revision | 53/53 FULL_READ; 배선된 필터 다섯(MVC 자동설정 2 + @Component 2 + WebFlux 자동설정 1); WebMvcRequestIdFilter(기본 trustInboundRequestId=false, 순서 HIGHEST_PRECEDENCE+10)가 UUID를 쓴 뒤 RequestLoggingFilter(무순서 → LOWEST_PRECEDENCE)가 클라이언트 헤더 값으로 응답 헤더·MDC를 덮어씀; HeaderSanitizer<0x20을 제거해 로그 인젝션은 아님; proxy 패키지 421 LOC 미배선이고 스푸핑 방어는 nginxProxyTest/resources/nginx/proxy_headers.conf가 location마다 헤더를 덮어쓰는 데 의존; server.forward-headers-strategy 기본값이 framework | 프록시 우회 도달 가능성은 배포 토폴로지에 의존하며 이 분석에서 확인하지 않음 | | SRC-137 | Advanced 능력 카탈로그와 게이트 | evidence/raw/198-inbound-web-advanced-probes.txt | same revision | 65/65 FULL_READ; WebAdvancedFeature 상수 11개, advanced/**의 프로덕션 @ConditionalOnProperty 접두사 2개(mvc-virtual-threads · ndjson) — 나머지 9개 능력은 프로퍼티도 @Configuration도 빈도 없음; WebAdvancedFeatureFlags(단일 활성화 표면으로 설계) 프로덕션 독자 0; VirtualThreadProfile.propertyName()virtual-threads(=mvc- 누락)를 반환하고 호출자 0; ndjson 스위치 하나가 NDJSON과 JSON_SEQUENCE 둘을 켬 | 능력이 off-by-default인 것은 build.gradle이 명시한 설계 | | SRC-138 | fileserver 조립 · 리액티브 활성화 조건 | evidence/raw/199-inbound-web-fileserver-probes.txt | same revision | 73/73 FULL_READ; 이 leaf에서 유일하게 완전히 조립된 하위 트리FileserverPlatformAutoConfiguration이 URI 매퍼·다운로드 전략·요청 컨텍스트 팩토리를 만들고 FileserverStartupConfiguration:87attestMapping()을 시작 시 호출; DefaultNginxInternalUriMapper가 앵커 정규식 + 구성 후 ..////\ 재검사; @ConditionalOnWebApplication(REACTIVE)가 걸린 29개 main 파일은 클래스패스가 SERVLET을 고정하므로 활성화 불가(leaf build.gradlespring-boot-starter-web 선언 · app-bootstrap lockfile에 tomcat/webmvc · sample-portfolio도 starter-web · setWebApplicationType main 사용 0) | WebApplicationType.deduceFromClasspath() 동작에 근거한 정적 판정 | | SRC-139 | notification 웹 표면 · admin | evidence/raw/200-inbound-web-notification-admin-probes.txt | same revision | 26/26 FULL_READ; CallbackRequestConfiguration이 게이트 아래에서 자기 의존을 @ConditionalOnMissingBean으로 공급하고 신뢰 프록시 기본값이 빈 집합("Honouring them unconditionally would let any caller choose the URL that gets signature-verified") — 닫힌 옵트인; SpringMvcRouteInventoryCollector(138줄)는 저장소 전체 참조가 자기 파일 2줄뿐(테스트도 0); WebPlatformStartupValidator(62줄)는 test 5 · main/boot 0으로 시작 시 실행되지 않음(fileserver의 attestMapping()과 대조) | — | | SRC-140 | testkit · 세 런타임 계약 레인 | evidence/raw/201-inbound-web-testkit-probes.txt | same revision | 94/94 FULL_READ; 계약 클래스 7종이 소스셋 분리와 dependsOn으로 세 런타임에 강제 적용(notification의 상속 3/8과 대조); WebArchitectureRules 7규칙이 app-bootstrap WebProductionArchitectureTest:47에서 프로덕션 트리에 적용되고 WebArchitectureRulesTest:86-88이 개수를 고정; 픽스처 애플리케이션 9개가 new로 세우는 플랫폼 타입 목록이 §16.1·§20.1·§36.1의 미배선 목록과 일치 — 레인이 인증하는 조립은 픽스처의 조립 | — | | EVD-077 | suite 검증 | §A14 §50.6 | 2026-08-30 | :adapter:inbound:web:test + :webSecurityBoundaryTestBUILD SUCCESSFUL, GRADLE_EXIT=0, classes=176 tests=1221 failures=0 errors=0 skipped=0; 이 모듈의 P1 6건·P2 8건 중 테스트가 검출한 것은 0건; git status --short 변경 0 | webCrossStackParityTest·webJettyCompatTest·webFluxContractTest·webNginxProxyTest·webAdvancedTest는 실행하지 않음(임베디드 서버 2종 다운로드 · Docker 런타임 요구) | | SRC-141 | 컴포지션 루트 스캔 경계 | evidence/raw/203-composition-root-scan-boundary.txt | same revision | CaSkeletonApplication.AUTO_CONFIGURED_PACKAGES 정규식이 web leaf의 다섯 패키지(mvc.error · mvc.budget · mvc.operation · webflux.error · webflux.operation)를 컴포넌트 스캔에서 제외하고 javadoc이 그 이유를 "Ownership by auto-configuration is what ties a control's presence to its dependency's"로 적음; 그 소유권을 넘겨받는 자동설정이 없음AutoConfiguration.imports 2줄 중 어느 것도 해당 타입을 만들지 않고, app-bootstrap의 해당 타입 참조 0, .imports/.factories 참조 0, WebProblemFactory 참조 0. 이 사실이 모듈 14 §8.1의 판정을 "두 계약 공존"에서 "RFC 9457 계약 23파일 미등록"으로 교체 | 정적 판정(정규식 · 등록자 부재); 실제 컨텍스트를 띄워 빈 목록을 확인하지는 않음 | | SRC-142 | module map / denominator | evidence/raw/202-inbound-grpc-module-inventory.txt | same revision | 18 tracked files(main 8 · test 6 · governance 4), main Java 602 LOC · test Java 782 LOC — 단일 bounded scope; build.gradle이 third-party grpc starter 없이 SmartLifecycle이 Netty 서버를 직접 소유하는 이유, .proto/protobuf 플러그인 부재, grpc-bom/protobuf-bom을 모듈 스코프에서 import해 strict-locking blast radius를 이 모듈에 가두는 이유를 명시; registerStrictQualificationTest가 두 클래스를 이름으로 요구 | 초기 sizing | | SRC-143 | gRPC 전송 계약 · 활성화 · 에러 매핑 | evidence/raw/204-inbound-grpc-probes.txt | same revision | 18/18 FULL_READ; 활성화 삼중 게이트@ConditionalOnProperty(matchIfMissing=false) + @AssertTrue isInsecureLocalConfigurationValid()(!enabled || (allowInsecureLocal && InetAddress.isLoopbackAddress())) + feature 서비스 존재 시 정확히 하나의 GrpcAuthenticationPolicy 요구; GrpcExceptionHandlingInterceptor가 네 실패 경로(handler throw · listener throw · onError · raw status)를 하나의 sanitizing close로 모아 호출자 description·트레일러를 폐기하고 code/category 트레일러로 재작성; GrpcStatusMapper.toStatusCategory 10값을 default 없이 전수 처리; errorCodeOf의 종료 조건이 getCause() == current 자기참조 검사뿐이라 2-순환에서 무한 루프 — 저장소 전체 9개 순회 지점 중 5개는 깊이 제한, 4개(grpc · MvcDisconnectDetector · WebFluxDisconnectDetector · TransactionRetryClassifier)는 자기참조 검사이며 JdkNotificationHttpGateway:93-97이 그 2-순환 사례를 이름으로 적고 깊이 제한을 택한 근거를 남김; BindableService·GrpcAuthenticationPolicy 구현 0 · ca-skeleton.grpc yaml 키 0은 CLAUDE.md가 선언한 상태 | 순환 사슬은 구성 가능성만 확인, 실제 무한 루프 재현은 하지 않음 | | EVD-078 | suite 검증 | §A15 §5 | 2026-08-30 | :adapter:inbound:grpc:test + :grpcTransportQualificationTestBUILD SUCCESSFUL, GRADLE_EXIT=0, classes=8 tests=48 failures=0 errors=0 skipped=0; qualification lane이 grpcTransportQualificationTest: 15 tests, 0 skipped 증거를 출력; GrpcP1BoundaryWireTest가 실제 loopback ephemeral Netty 서버로 와이어 계약을 확인 | — | | SRC-144 | module map / denominator | evidence/raw/205-inbound-graphql-module-inventory.txt | same revision | 534 tracked files(main 411 · test 103 · testFixtures 16 · governance 4), main Java 26,303 LOC · test Java 13,671 LOC, main 패키지 41개; 11개 sub-scope 분할을 패키지 트리에서 기계 계산(중복 0 · 미할당 0) | 초기 sizing | | SRC-145 | 패키지 도달성 지도 | evidence/raw/206-inbound-graphql-package-reachability.txt | same revision | main 41개 패키지의 in(다른 graphql main 패키지가 import) / ext(app-bootstrap·sample-portfolio가 import) 기계 산출 — ext 열이 전 패키지 0이며, 이는 컴포지션 루트가 dev\.caskeleton\.adapter\.inbound\.graphql\..*를 컴포넌트 스캔에서 제외하고 조립을 자동설정 진입점 하나에 맡긴 구조와 일치 | import 기반; 자동설정 @Bean 참조는 별도 삼중 카운트로 확인 | | SRC-146 | 조립 진입점 · off 계약 | evidence/raw/207-inbound-graphql-autoconfigure-probes.txt | same revision | 60/60 FULL_READ; .imports 한 줄이 마스터 게이트를 든 얇은 루트(GraphQlRootAutoConfiguration)이고 704줄 플랫폼 설정(39 @Bean · 43 @ConditionalOn)을 @Import; spring.factoriesAutoConfigurationImportFilter(프레임워크 GraphQL 자동설정 10개를 이름으로 차단)와 EnvironmentPostProcessor(은퇴 키 거부 + 콘솔 플래그 기본값 기여)를 건다; off 계약의 두 절반이 app-bootstrap GraphQlShippedAndGatedTest(빈 인벤토리 + 실제 포트에서 미매핑 경로와 상태코드 동일성)로 검증됨; HealthGraphqlController@Import에 있고 그 주석이 "no root imported it ... Its own tests passed throughout by registering the class themselves"로 과거 결함을 기록 — 모듈 14 §8.1과 같은 형태를 여기서는 닫음 | 프레임워크 자동설정 목록은 하드코딩이며 새 항목 추가에 대한 드리프트 검사는 없음 | | SRC-147 | 스키마 거버넌스 · 스칼라 · 호환성 | evidence/raw/208-inbound-graphql-schema-probes.txt | same revision | 46/46 FULL_READ; 파일 단위 삼중 카운트로 GraphQlScalarWiringConfigurer(7) · GraphQlScalarManifest(4) · GraphQlMappingInspectionGate(3) 배선 확인; GraphQlSchemaAssembler·GraphQlSchemaContract·GraphQlOneOfSchemaGate·GraphQlOneOfInputValidator autoconf=0 main_other=0이고 GraphQlSchemaHash 생산 사슬(assemble → AssemblyResult.schemaHash)의 유일한 소비자 GraphQlPlatformActuatorEndpoint@Bean이 없음; graphql-java 25.0이 @oneOf를 자체 처리 | 조립 순서 불안정의 실제 발현은 조각이 하나뿐이라 관측 불가 | | SRC-148 | 실행 사슬 · 요청 컨텍스트 · 예산 계층 | evidence/raw/209-inbound-graphql-execution-probes.txt | same revision | 60/60 FULL_READ; runtime 19종 대부분이 autoconf 27로 배선; 설계 §10의 5계층 예산 중 요청 계층만 강제(GraphQlPlatformWebInterceptor:135 + GraphQlRequestContext.withDeadline 단조 조이기 + GraphQlCancellation) 되고 GraphQlDeadlinePropagator의 파생 다섯 메서드는 호출자 0 — GraphQlTimeoutPolicy·GraphQlResolverBudget 참조자가 전부 미배선 클러스터 내부; 익명 연산 거부는 배선된 GraphQlOperationSelectionHandler가 네 가지 케이스로 수행 | 다운스트림 데드라인 미전달의 실제 영향은 아웃바운드 어댑터 기본값에 의존 | | SRC-149 | 비용 제어 · 정책 · 보안 | evidence/raw/210-inbound-graphql-cost-security-probes.txt | same revision | 57/57 FULL_READ; 구조 한계·복잡도·관측은 배선(autoconf 46); GraphQlParserOptionsFactory(파서 한계를 graphql-java 정적 전역에 설치) 호출자 0backend.graphql.limits.*GraphQlClientPolicyGraphQlParserLimits.from(...) 사슬이 끊김; GraphQlClientPolicyManifest 미배선이고 자동설정이 GraphQlClientPolicy.defaults(properties) 단일 빈을 8곳에 주입 — 자격에서 해석된 프로파일이 예산을 고르지 않음; GraphQlContextPropagator는 네 진입점 전부 finally 복원으로 누수 없음 | graphql-java 기본 파서 한계가 백스톱으로 작동 | | SRC-150 | HTTP 전송 계약 · 오류 · 관측 | evidence/raw/211-inbound-graphql-http-probes.txt | same revision | 48/48 FULL_READ; main 전체에 GraphQlHttpHandler/RouterFunction/@PostMapping이 없어 이 leaf는 HTTP 엔드포인트를 소유하지 않음/graphql은 Spring GraphQL이 발행; http 19파일 중 값으로 소비되는 둘(GraphQlHttpProfile 2 · GraphQlJsonStructurePolicy 4)을 뺀 전송 기계는 닫힌 섬; GraphQlRequestErrorMapper(파싱·검증 실패 매퍼) 미배선; 관측 9종은 autoconf 36으로 배선 | 프레임워크가 GraphQL-over-HTTP 스펙을 구현하므로 동작 자체는 합리적 | | SRC-151 | DataLoader · 커서 · 뮤테이션 | evidence/raw/212-inbound-graphql-data-probes.txt | same revision | 69/69 FULL_READ; dataloaderGraphQlBatchLoaderRegistrar(autoconf=4) 경유로 배선; backend.graphql.cursor.key-ids를 읽는 프로덕션 코드가 시작 검증기와 액추에이터 둘뿐이고 서명하는 코드가 없음HmacGraphQlCursorCodec·GraphQlCursorKeyRing autoconf=0 main_other=0; 이 사실을 autoconfigure/GraphQlPolicyRequestPathTest(GQL-INT-003)가 javadoc으로 기록하고 "this test fails the moment somebody wires one half without the other"로 고정 | 페이지네이션이 어떤 feature에도 붙지 않아 현재 조작 대상 커서 자체가 없음 | | SRC-152 | capability 등급표 대조 | evidence/raw/213-inbound-graphql-release-probes.txt | same revision | 10/10 FULL_READ; CLAUDE.md가 4등급(modelled/wired/integration-verified/production-verified)을 정의하고 "현재 등급보다 높게 표현하지 않는다"를 규칙으로 선언, 13행 중 일곱을 스스로 modelled로 강등; sub-scope 0206의 파일 단위 배선 데이터와 13행을 전수 대조한 결과 12행 일치, 요청 크기/Accept 협상 (http/) 한 행만 불일치 — 인용된 두 증거(GraphQlRequestBoundsTest·GraphQlAcceptNegotiationTest)가 endpoint 테스트가 아닌 순수 단위 테스트이고 대상 타입은 autoconf=0; GraphQlStableCapabilityManifest.STABLESIGNED_CURSOR_CONNECTION을 포함해 등급표(modelled)와 불일치 | 등급표는 사람이 읽는 문서, 매니페스트는 requireStable이 소비하는 기계 판정 | | SRC-153 | Advanced 3개 sub-scope | evidence/raw/214·215·216-inbound-graphql-advanced-*-probes.txt | same revision | 163/163 FULL_READ(51+53+59); 141개 Advanced main 파일 전부 autoconf=0이고 등급표의 modelled 선언과 일치; 타입 이름이 등급을 인코딩(*Admission — "Spring transport handler 는 없다(그래서 타입 이름도 *Admission 이다)"); *HandlerFactory 세 종은 "Decides whether the handler may exist, and on what terms"로 판정만 수행; advanced/springdataUNSUPPORTED로 강등되며 "a capability flag cannot make an architectural rule conditional" 근거 기록; GraphQlAdvancedFeatureFlags@ConfigurationProperties가 아니라 정적 팩토리만 가져 활성화 설정 표면이 없음 | Advanced 진입점이 .imports에 없음 — 등급표는 이를 modelled로 인정 | | EVD-079 | suite 검증 | §A16 §45.4 | 2026-08-30 | :adapter:inbound:graphql:testBUILD SUCCESSFUL, GRADLE_EXIT=0, classes=186 tests=1603 failures=0 errors=0 skipped=0 | graphqlStableTest(605) · graphqlContractTest(9) · graphqlAdvancedTest(152) 세 플랫폼 레인은 실행하지 않음 | | SRC-154 | module map / denominator | evidence/raw/218-inbound-websocket-module-inventory.txt | same revision | 253 tracked files(main 169 · test 67 · testkit 7 · nginxWebSocketTest 4 · jettyWebSocketTest 1 · brokerRelayTest 1 · governance 4), main Java 12,784 LOC · test Java 9,435 LOC, main 패키지 39개; META-INF 자동설정 리소스 없음 — 조립이 전적으로 컴포넌트 스캔에 달림 | 초기 sizing | | SRC-155 | 세 설정 네임스페이스와 조립 지점 | evidence/raw/219226-inbound-websocket-*-probes.txt | same revision | 253/253 FULL_READ; 169개 main 파일 중 Spring 애노테이션을 가진 것이 7개이고 설정 접두사가 셋으로 갈림 — ca-skeleton.websocket.*(stomp/WebSocketConfig가 소비, 실제 동작) · app.websocket-platform.advanced.*(4개 @Configuration) · backend.websocket.*(WebSocketPlatformSettings, 소비 @Configuration 0); 세 접두사 모두 어떤 application.yml에도 없음; WebSocketPlatformStartupValidator(125)와 WebSocketStackExclusivity(78) 프로덕션 호출자 0 — 후자는 inbound-web §40.1(서블릿/리액티브 이중 스택에서 리액티브 엔드포인트가 조용히 응답하지 않는 상태)을 이름 붙여 탐지하는 클래스; CLAUDE.md Responsibility 5줄·Typed settings 4키가 stomp 8파일만 서술하고 Evidence 절의 면책("Broker relay, multi-node/durable delivery, rollback-safe publication, replay/resume, backpressure ... are not claimed")이 advanced/stomp/rabbit(7)·cluster(9)·resume(8)·outbound(8)를 덮되 90개 플랫폼 파일은 덮지 않음 | evidence 파일의 autoconf 열은 graphql 스크립트 재사용으로 이 leaf에서는 전 행 0이며 정보가 없음 — 배선 판정은 애노테이션 전수와 main_other 열로 수행 | | EVD-080 | suite 검증 | §A17 §26.5 | 2026-08-30 | :adapter:inbound:websocket:testBUILD SUCCESSFUL, GRADLE_EXIT=0, classes=91 tests=720 failures=0 errors=0 skipped=0; P1(§4.1)은 720개 전부 통과 상태에서 나옴 | 커스텀 레인 4종(websocketNginxTest·websocketBrokerRelayTest·websocketAdvancedTest·websocketJettyTest)은 Docker·별도 서버·외부 브로커를 요구해 실행하지 않음 | | SRC-156 | module map / denominator | evidence/raw/227-app-bootstrap-module-inventory.txt | same revision | 455 tracked files(main 157 · test 288 · functionalTest 4 · sampleOffTest 1 · conditionalTransportTest 1 · governance 4 · resources 7), main Java 12,380 LOC · test Java 32,568 LOC(main의 2.6배); 조립 표면 전체가 네 리소스 파일 — .imports 6줄 · spring.factories(EnvironmentPostProcessor 6 · SpringBootExceptionReporter · AutoConfigurationImportFilter · ApplicationListener) · ManagementContextConfiguration .imports 1줄 · CaSkeletonApplication의 두 스캔 | 초기 sizing | | SRC-157 | 활성화 모델과 런타임 멤버십 | evidence/raw/228-app-bootstrap-activation-probes.txt | same revision | 62/62 FULL_READ; shared-contractMasterSwitch enum이 다섯 어댑터(jpa·mongo·messaging·notification·graphql)의 프로퍼티·환경변수를 SSOT로 갖고 네 장치를 구동 — 값 문법 거부(MasterSwitchEnvironmentPostProcessor) · 능력 의존 검증(CapabilityDependencyValidator 156) · 액추에이터 보고(AdapterActivationReport, "the application's answer" ) · 삼자 일치 테스트(enum ↔ docs/registries/env-keys.yamlapplication.yml, "a sixth adapter cannot be added without this test demanding its row"); modules.jsonruntime_memberships가 grpc=[] · websocket=[] · graphql=["app-bootstrap"] · web=["app-bootstrap","sample-portfolio"] 이고 ConditionalTransportCompositionContractTest가 grpc·websocket을 BUILD_ONLY_TRANSPORTS("nothing may put them on a runtime")로 기계 강제 — 두 어댑터가 활성화 모델 밖인 것은 누락이 아니라 일관성; 남는 것은 출하되는 web의 스위치 넷(backend.web.mvc·webflux 기본 켜짐 포함)이 모델 밖이라는 점 | 이 확인으로 모듈 17 §4.1을 P1 → P2로 하향(모듈 17 §26.6) | | SRC-158 | 시작 검증기 배선과 조립 규칙 | evidence/raw/230-app-bootstrap-runtime-probes.txt | same revision | 85/85 FULL_READ; runtime 검증기 12종 전부 배선(RuntimeSafetyConfig @Bean 7 · SecretSourceConfig · MigrationStartupConfig), StartupFailures(75)가 구조화 실패 로그의 단일 발생원이며 종료 코드까지 규정(STARTUP_VALIDATION_FAILED=78 · MigrationFailed=70); main 157 파일에 고아 0 — 참조 0인 파일은 전부 @Configuration 루트 · logback 컴포넌트(logback-spring.xml이 클래스명으로 등록) · spring.factories 항목으로 설명됨; 시작 검증기가 도는지 여부가 그 능력에 자동설정 루트가 있는지와 정확히 일치(app-bootstrap 12종·fileserver attest·graphql 검증기는 배선, web·websocket 검증기는 미배선) | — | | SRC-159 | 아키텍처 규칙 · 계약 레인 | evidence/raw/233·234·235-app-bootstrap-*-probes.txt | same revision | 172/172 FULL_READ(90+54+28); 아키텍처 규칙 14종이 프로덕션 트리에 적용되고 위반/허용 합성 픽스처 76개가 각 규칙의 양방향을 고정; 계약 테스트 30종이 레지스트리 일치·관측/로그·보안 표면·활성화·실패 분류·운영을 덮고 ContractSuiteCompletenessTest가 스위트 자체의 완전성을 검사; dev.caskeleton.onboarding.** 14파일이 "새 feature 추가 절차"의 합성 대상; 대체 소스셋 3종(functionalTest 4 · sampleOffTest 1 · conditionalTransportTest 1) | — | | EVD-081 | suite 검증 | §A18 §26 | 2026-08-30 | :app-bootstrap:testGRADLE_EXIT=1, classes=169 tests=1016 failures=1 errors=0 skipped=4; 유일한 실패 ComposeMergeCharacterizationTest.everyLaneMatchesItsContract환경 원인(분석 컨테이너에 jq 없음 → verify-compose-profile-contracts.sh exit 78); 같은 테스트가 docker compose 부재는 Assumptions.assumeTrue로 skip하고 jq 부재는 실패로 처리하는 가드 비대칭을 P3로 기록 | 나머지 1,015개 통과 | | SRC-160 | family map / denominator | evidence/raw/251-messaging-leaf-registry-and-memberships.txt · 243-messaging-core-contracts-inventory.txt | same revision | modules.json에서 gradle_path:messaging:인 leaf 25개 전량 열거 + 각 source_pathgit ls-files — leaf 합계 549, src/messaging 전체 550(차이는 가족 공통 CLAUDE.md 1개), 미배정 0; 출하 18 / build-only 7(testkit · pulsar · nats · scs-bridge · schema-avro · kafka-share · schema-protobuf); 등록 id 주의 — messaging-inbox-jdbc-postgresql / messaging-outbox-jdbc-postgresql / messaging-cloudevents(schema 접두사 없음), 디스크의 -inbox-jpa·-outbox-jpa는 추적 파일 0으로 분모 밖 | 이 18/7 구분이 문서 전체의 심각도 축(모듈 17 §26.6 원칙 적용) | | SRC-161 | 공개된 주장의 검증 | evidence/raw/244-messaging-fault-scenarios-vs-evidence.txt · 246-messaging-mss022-no-string-matching.txt · 250-messaging-certification-gate-chain.txt | same revision | src/messaging/CLAUDE.md의 Stable 승격 조건 중 코드로 검증 가능한 3개 전부 성립; MSG-022(예외 타입 문자열 판별) 25 leaf main 전체 0건; NetworkFaultScenario 5개 중 Kafka 증거 4개이고 미커버 connection-refusedCertifiedEvidence.knownGapsall() - covered파생하며 aScenarioWithNoLineInTheManifestIsAGapRatherThanAnAbsence가 이유까지 단언; verifyMessagingCertificationEvidence가 실행 산출물 ↔ 커밋 manifest를 양방향 대조(gitCommit·observedAt은 정규식 제거 후 비교, upToDateWhen{false}, CI가 src/messaging/** PR마다 실행) — manifest의 gitCommit: e98b56eb03ec가 분석 리비전보다 오래된 것은 설계상 정상 | 19개 모듈 중 공개 주장이 검증을 그대로 통과한 첫 사례 | | SRC-162 | capability 집행 · profile validator 도달성 | evidence/raw/247-messaging-capability-flag-enforcement-census.txt · 248-messaging-profile-validator-reachability.txt · 249-messaging-startup-profile-validation.txt | same revision | MessagingCapabilities 12개 플래그의 main/test 참조 전수 — main이 읽는 것 3개(orderedStream·delayedDelivery는 분기, deduplicatedPublish만 거부), 나머지 9개는 모든 어댑터가 선언하고 아무 곳도 읽지 않음(census의 orderedStream main=5 중 4건은 Spring ObjectProvider.orderedStream() 오탐); javadoc의 "fails loudly — at startup where possible"가 성립하지 않음; profile validator 8개 중 조립 3개(DestinationProfileValidator·KafkaProfileValidator·RabbitProfileValidator) — 미조립 5개 중 4개는 build-only leaf라 등급 일치이고 출하 leaf의 KafkaTransactionProfileValidator만 결함(@Bean 선언만, 주입처 없음); StartupProfileValidation javadoc이 같은 결함을 이미 한 번 고친 이력을 서술 | PulsarProfileValidator는 테스트조차 0건 | | SRC-163 | schema · 관측 도달성 | evidence/raw/252-messaging-schema-and-observability-reachability.txt · 253-messaging-observation-assembly.txt · 254-messaging-autoconfiguration-imports.txt | same revision | 출하 leaf의 main 참조 0 타입 — SchemaCompatibilityValidator(+SchemaRegistry main 구현 없음, DestinationSettingsNONE_EXPERIMENTAL 설정을 허용), DefaultCloudEventMapper(출하 leaf이자 starter implementation 의존인데 소비자 0), MessagingMetrics·MessagingTracer·MessagingAuditSink·DefaultMessagingObservationConvention; MessagingCoreAutoConfiguration이 관측 관련으로 등록하는 것은 MessagingRedactor·CardinalityGuard 협력자 둘뿐이고 messagingPublisher가 6인자 생성자를 골라 NO_OBSERVATION을 주입 — DefaultMessagePublisher 필드 javadoc의 "an unobserved publish path is how 'the dashboards were empty during the incident' happens"가 조립에서 되돌려짐; AutoConfiguration.importsMessagingPlatformRootAutoConfiguration 1줄 | 자동설정 클래스 28개, 그중 .imports에 오르는 것은 루트 하나 | | SRC-164 | 문서 ↔ 코드 capability 전수 대조 | evidence/raw/255-messaging-capability-doc-vs-code-drift.txt | same revision | docs/messaging/support-matrix.md capability 표 60칸(5 어댑터 × 12 플래그)을 각 어댑터의 MessagingCapabilities 배열과 전수 대조 — 불일치 1칸: Kafka deduplicatedPublish가 문서 O, 코드 false; 그 플래그가 12개 중 유일하게 거부를 발생시키는 것이고, KafkaMessagingTransport의 javadoc이 true→false 변경 이력과 "the caller believes the broker is deduplicating and skips the idempotency it would otherwise build"라는 피해를 서술; Rabbit·Kafka Share·Pulsar·NATS 48칸 전부 일치(Pulsar keyedOrdering은 SHARED/KEY_SHARED 두 배열 차이까지 문서가 반영); Rabbit delayedDelivery는 코드 true / 문서 retry queue로 대행으로 문서가 더 정확 | 이 문서 유일의 P1 | | SRC-165 | 전송 선택 · Rabbit 조립 · MSG-015 | evidence/raw/256-messaging-provider-selection-and-rabbit-transport.txt · 257-messaging-msg015-two-kafka-stacks.txt | same revision | MessagingPlatformRootAutoConfigurationapp.messaging.enabled=truematchIfMissing 없이 단독 소유하고 자식은 조건을 갖지 않음 — 모듈 14 §8.1이 web에서 없다고 판정한 구조를 messaging은 보유; MessagingProviderSelection.BROKERS_WITHOUT_A_TRANSPORTrabbit 등록(RabbitChannelPublisher 구현 0건, RabbitMessagingAutoConfigurationMessagingTransport bean 없음) — 코드는 fail-closed로 정직하나 support-matrix.md의 RabbitMQ 행 "제한" 칸이 이를 말하지 않음; MSG-015는 bean 이름 분리로 증상만 사라짐 — KafkaSenderConfigapp.messaging.broker=kafka만 보고 enabled를 보지 않아 CLAUDE.md가 말한 기본값 보호(enabled=false)가 두 스택 중 하나만 막음 | 오늘 실제 배포 가능한 브로커는 Kafka 하나 | | SRC-166 | reliability · admin 도달성 | evidence/raw/258-messaging-reliability-and-admin-reachability.txt · 260-messaging-admin-plane-assembly.txt | same revision | MessagingReliabilityAutoConfiguration의 조건 사슬 뿌리가 OutboxRepository/InboxRepository bean인데 유일 구현 JdbcOutboxRepository·JdbcInboxRepository의 main 참조가 0 — relay·worker·lifecycle·cleanup·transactional inbox handler 전부 미조립(19 main 파일 2,818 LOC); MessageCodecRegistry에는 @ConditionalOnMissingBean 기본 구현을 제공한 같은 starter가 여기서는 만족되지 않는 @ConditionalOnBean을 남김; claim-check 배선 0; admin plane은 app.messaging.admin.enabled=true fail-closed에 DestructiveOperationGuard(false)·비내구 journal + MessagingAdminDurabilityValidator(production 프로파일 거부, 공급할 클래스 이름을 메시지에 포함)로 가드는 우수하나 DefaultMessagingAdminService·HmacApprovalVerifier·TopologyValidationRuntime main 참조 0 — 부재 4건 중 DestructiveMessagingAdmin 하나만 javadoc으로 설명됨 | admin 게이트는 이 저장소에서 가장 잘 조립된 축에 속함 | | SRC-167 | Flyway 마이그레이션 네임스페이스 | evidence/raw/259-messaging-flyway-migration-namespace.txt | same revision | 합성 루트의 기본 Flyway 위치는 PostgreSqlPersistenceConfig:115classpath:db/migration/postgresql이고 조건부 스트림은 각자 위치·history table 보유; db/migration/messaging을 이름으로 부르는 main 코드가 저장소 전체에 0건messaging_outbox·messaging_inbox·admin journal 테이블은 어떤 출하 배포에서도 생성되지 않음; 두 leaf가 같은 리소스 디렉터리에 각자 번호를 매겨 V2가 둘(V2__messaging_inbox.sql CREATE TABLE vs V2__messaging_outbox_lease_fencing.sql ALTER×4) — 그 위치를 Flyway에 주는 순간 duplicate version으로 부팅 실패; 각 leaf의 IT는 자기 jar 리소스만 보므로 재현 불가; 원래 구현 계획서(plan:3010·3011·3032·3055)는 messaging-outbox/messaging-inbox 분리 위치를 지정했음 | persistence-jpa가 세 번 고친 "bean은 있고 테이블은 없다"의 정반대 형태 | | SRC-168 | 문서 계약 테스트 커버리지 · 설정 위생 | evidence/raw/261-messaging-documentation-contract-test-coverage.txt · 262-messaging-starter-configuration-validators.txt | same revision | MessagingDocumentationContractTest가 doc rot를 막기 위해 존재하고("the only signal is an operator making a decision on a page that stopped being true months ago") 단언 8개를 가짐 — 등급 이름·Kafka 버전·존재하지 않는 두 상수는 정확히 붙들고, capability 표·runtime membership 문장·브로커 등급표의 "제한" 칸은 커버리지 밖이며 이 문서가 찾은 문서 드리프트 3건이 전부 그 밖에 위치; 약한 단언 둘(theExperimentalPolicyStatesThatExperimentalIsOffByDefault = contains("false"), everyDocumentHasContent = 500자 초과); starter는 시작 검증기 6종을 실제로 실행 — MessagingPrefixMigrationValidator(죽은 세 네임스페이스) · MessagingConfigurationKeyValidator(적법 키를 settings record에서 파생) · MessagingCredentialRequirementValidator(production 한정) · MessagingAdminDurabilityValidator · Kafka/Rabbit StartupProfileValidation 2종 | MessagingPublicSurfaceContractTestsrc/messaging 밖(app-bootstrap) 레인에 있음 | | EVD-082 | suite 검증 | §A19 §12.1 | 2026-08-31 | 25개 messaging leaf의 :test 전량 → BUILD SUCCESSFUL in 2m 27s, GRADLE_EXIT=0, 105 actionable tasks(59 executed · 46 up-to-date); XML 집계 classes=110 tests=851 failures=0 errors=0 skipped=0; git status --short 출력 없음 | messagingCertificationTest/verifyMessagingCertificationEvidence(Docker 필수, 의도적으로 가드 없음)와 Testcontainers 계열 IT는 실행하지 않음 | | SRC-169 | 리비전 이동 · 분모 재확정 | evidence/raw/264-cross-scope-autoconfiguration-roots.txt(이동 전) · 265·268(이동 후) | 21234e38 | 교차 스코프 준비 중 레지스트리 전수 재독으로 등록 62 / state.json 44 불일치 발견. 원인은 분석 누락이 아니라 리비전 이동 — 기준선 a24ece9c 이후 21234e38("feat: grpc 기능 deep 구현", 2026-08-31)이 gRPC 가족을 추가했다. git diff a24ece9c..HEAD = 400 files / +40,217 / 4이고 변경 경로는 src/grpc* · modules.json(18항목 추가) · src/build.gradle(테스트 클래스패스 조건) · docs 15개뿐 — src/messaging 이하 무변경, 모듈 01~19 전부 유효; state.jsongitRevision을 HEAD로 올리고 reanalysisADDITIVE_SCOPE로 채운 뒤 18 스코프 추가 | evidence 264는 이동 전 트리(.imports 7 · leaf 44), 265·268은 이동 후(.imports 8 · leaf 62) | | SRC-170 | grpc family map / denominator | evidence/raw/266-grpc-family-claims-verification.txt | same revision | 18 leaf · 추적 383(leaf 381 + 가족 CLAUDE.md 2) · main Java 260 / 18,726 LOC · test Java 74; 18개 전부 runtime_memberships: [] — 가족 전체 build-only이고 이것이 심각도 축(모듈 17 §26.6 원칙); adapter-inbound-grpcallowed_dependencies[domain-core, application-core, shared-contract]로 이 가족을 볼 수 없음 — messaging MSG-015 재발 방지가 레지스트리로 성립 | 6개 sub-scope, 미배정 0 | | SRC-171 | 공개 주장 검증 (6건 성립) | evidence/raw/266-grpc-family-claims-verification.txt | same revision | grpc-core-api의 io.grpc 참조 3건은 전부 javadoc 산문이고 build.gradledependencies {}비어 있어 framework-free가 클래스패스로 강제됨(32 main / 1,897 LOC); Stable→advanced 금지가 레지스트리 0위반 · 소스 0참조 · verifyCleanArchitectureDependencies · GrpcStableBuildInvariant 네 층 일치; GrpcEvidenceGrade.requireCertifies가 CONTRACT 등급의 tls 주장을 런타임 거부(등급 집합을 필드가 아니라 switch로 계산); 성능 레인 excludeTags 'grpc-performance'; 지원 매트릭스가 "Not released … build-only"와 미해결 게이트 입력 2건을 스스로 공시 — 모듈 19 §6.4(messaging 매트릭스의 반대 주장)와 정반대 사례; GrpcCompatibilityMatrix.caSkeleton() 7레인이 문서 표와 전수 일치 | Spring Boot 4.0.8 · UDS resolver 존재도 문서와 일치 | | SRC-172 | 조립·릴리스 게이트 도달성 | evidence/raw/267-grpc-family-reachability.txt · 268-grpc-assembly-and-release-gate.txt | same revision | 유일한 조립 지점 GrpcPlatformAutoConfiguration(.imports 1줄, matchIfMissing=false fail-closed)이 @Bean 9개를 등록하는데 전부 프로파일·정책이고 서버·인터셉터 체인·서비스 어댑터가 없음; GrpcPlatformStartupValidator main 참조 0(static 메서드, 호출자 없음)인데 CLAUDE.md는 "startup을 거부한다"고 현재 시제 서술 — 모듈 14 §44.2·17 §4.1·19 §3.5에 이은 네 번째이자 자동설정 루트가 있는데도 부르지 않는 첫 사례; GrpcReleaseEvidence 생성 지점 4개가 전부 GrpcStableReleaseGateTest이고 runbookPresent 등 5성분이 호출자 제공 boolean — messaging이 manifest 파일 + Gradle 태스크 + CI 워크플로 3층으로 이미 닫은 모양의 재발; src/grpc*/*/build.gradletasks.register 0건, 28개 CI 워크플로 중 grpc 언급 0건; main 260 중 참조 0이 73개(공개 API 표면 포함)이고 그중 조립·기계 타입 15종이 결함 | GrpcServerInterceptorChain javadoc이 "every codebase gets it backwards at least once"라 적고 조립에서 안 쓰임 | | SRC-173 | 증거 레인의 실행 경로 | evidence/raw/268-grpc-assembly-and-release-gate.txt | same revision | ca.strict-test-lane.gradle이 레인을 verification 그룹 Test 태스크로 등록만 하고 check에 연결하지 않음(check-dependsOn 0건); CI는 ci-quality-gates.yml:50./gradlew check로 각 leaf 기본 test만 커버; 따라서 CONTRACT/TRANSPORT/FAULT 등급을 뒷받침하는 25개 테스트(7+9+9)가 누군가 명령을 직접 입력할 때만 실행됨 — 직접 실행해 전부 통과 확인(BUILD SUCCESSFUL, exit 0); 모듈 18 §4.1c의 "a gate that is red in a lane nobody runs locally…"가 그대로 적용되되 이쪽은 오늘 초록 | messaging은 같은 제외에 전용 CI 워크플로라는 대체 경로가 있음 | | EVD-083 | suite 검증 | §A20 §6.1 | 2026-08-31 | 18개 grpc leaf :test 전량 → BUILD SUCCESSFUL in 1m 12s, GRADLE_EXIT=0, 81 actionable tasks; XML 집계 classes=71 tests=579 failures=0 errors=0 skipped=0; 증거 레인 3종 직접 실행 → BUILD SUCCESSFUL, exit 0 (inProcess 7 · netty 9 · fault 9, 전부 0 실패 0 skip, Netty는 실제 소켓); git status --short 출력 없음 | grpcPerformanceTest는 공유 러너 측정이 baseline이 될 수 없어 미실행 |


E. 스코프별 커버리지

분석 중에는 state.jsonscopes 였다. 리프 단위 정본이던 자리다.

분석한 리비전은 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 이다.

스코프 경로 상태 전량 통독 구조만 제외 옮겨 간 자리
domain-core src/domain-core COMPLETE 10 3 1 §A01
shared-contract src/shared-contract COMPLETE 82 4 0 §A02
application-core src/application-core COMPLETE 1021 0 0 §A03
adapter-outbound-support src/adapter/outbound/support COMPLETE 8 0 0 §A04
adapter-outbound-persistence-jpa src/adapter/outbound/persistence-jpa COMPLETE 605 0 0 §A05
adapter-outbound-persistence-mongo src/adapter/outbound/persistence-mongo COMPLETE 497 0 0 §A06
adapter-outbound-identifier src/adapter/outbound/identifier COMPLETE 10 0 0 §A07
adapter-outbound-fileserver src/adapter/outbound/fileserver COMPLETE 119 0 0 §A08
adapter-outbound-objectstorage src/adapter/outbound/objectstorage COMPLETE 206 0 0 §A09
adapter-outbound-cache-redis src/adapter/outbound/cache-redis COMPLETE 390 0 0 §A10
adapter-outbound-httpclient src/adapter/outbound/httpclient COMPLETE 370 0 0 §A11
adapter-outbound-messaging src/adapter/outbound/messaging COMPLETE 69 0 0 §A12
adapter-outbound-notification src/adapter/outbound/notification COMPLETE 240 0 0 §A13
adapter-inbound-web src/adapter/inbound/web COMPLETE 638 0 0 §A14
adapter-inbound-grpc src/adapter/inbound/grpc COMPLETE 18 0 0 §A15
adapter-inbound-graphql src/adapter/inbound/graphql COMPLETE 534 0 0 §A16
adapter-inbound-websocket src/adapter/inbound/websocket COMPLETE 253 0 0 §A17
app-bootstrap src/app-bootstrap COMPLETE 455 0 0 §A18
sample-portfolio src/sample-portfolio EXCLUDED 0 0 0 §A19
messaging-core-api src/messaging/messaging-core-api COMPLETE 71 24 0
messaging-schema-api src/messaging/messaging-schema-api COMPLETE 14 1 0
messaging-schema-json src/messaging/messaging-schema-json COMPLETE 5 1 0
messaging-schema-avro src/messaging/messaging-schema-avro COMPLETE 6 2 0
messaging-schema-protobuf src/messaging/messaging-schema-protobuf COMPLETE 6 0 0
messaging-cloudevents src/messaging/messaging-cloudevents COMPLETE 6 0 0
messaging-policy src/messaging/messaging-policy COMPLETE 31 1 0
messaging-transport-spi src/messaging/messaging-transport-spi COMPLETE 18 1 0
messaging-runtime-core src/messaging/messaging-runtime-core COMPLETE 11 1 0
messaging-observability src/messaging/messaging-observability COMPLETE 16 1 0
messaging-security src/messaging/messaging-security COMPLETE 16 1 0
messaging-kafka src/messaging/messaging-kafka COMPLETE 59 1 0
messaging-kafka-share-experimental src/messaging/messaging-kafka-share-experimental COMPLETE 6 1 0
messaging-rabbit src/messaging/messaging-rabbit COMPLETE 31 1 0
messaging-reliability-api src/messaging/messaging-reliability-api COMPLETE 14 1 0
messaging-outbox-jdbc-postgresql src/messaging/messaging-outbox-jdbc-postgresql COMPLETE 19 8 0
messaging-inbox-jdbc-postgresql src/messaging/messaging-inbox-jdbc-postgresql COMPLETE 12 1 0
messaging-claim-check src/messaging/messaging-claim-check COMPLETE 10 1 0
messaging-admin-api src/messaging/messaging-admin-api COMPLETE 25 0 0
messaging-admin-runtime src/messaging/messaging-admin-runtime COMPLETE 18 0 0
messaging-pulsar-experimental src/messaging/messaging-pulsar-experimental COMPLETE 11 1 0
messaging-nats-experimental src/messaging/messaging-nats-experimental COMPLETE 10 1 0
messaging-spring-cloud-stream-bridge src/messaging/messaging-spring-cloud-stream-bridge COMPLETE 9 1 0
messaging-spring-boot-starter src/messaging/messaging-spring-boot-starter COMPLETE 40 1 0
messaging-testkit src/messaging/messaging-testkit COMPLETE 20 1 0
grpc-core-api src/grpc/grpc-core-api COMPLETE 40 1 0
grpc-proto-contract src/grpc/grpc-proto-contract COMPLETE 10 1 0
grpc-codegen src/grpc/grpc-codegen COMPLETE 15 1 0
grpc-policy src/grpc/grpc-policy COMPLETE 82 1 0
grpc-server src/grpc/grpc-server COMPLETE 23 1 0
grpc-client src/grpc/grpc-client COMPLETE 19 1 0
grpc-discovery src/grpc/grpc-discovery COMPLETE 10 1 0
grpc-admin src/grpc/grpc-admin COMPLETE 19 1 0
grpc-observability src/grpc/grpc-observability COMPLETE 6 1 0
grpc-operation-ledger-jpa src/grpc/grpc-operation-ledger-jpa COMPLETE 6 1 0
grpc-spring-boot-starter src/grpc/grpc-spring-boot-starter COMPLETE 7 1 0
grpc-testkit src/grpc/grpc-testkit COMPLETE 35 1 0
grpc-advanced-bootstrap src/grpc-advanced/grpc-advanced-bootstrap COMPLETE 12 1 0
grpc-advanced-edition src/grpc-advanced/grpc-advanced-edition COMPLETE 10 1 0
grpc-advanced-streaming src/grpc-advanced/grpc-advanced-streaming COMPLETE 20 1 0
grpc-advanced-resilience src/grpc-advanced/grpc-advanced-resilience COMPLETE 22 1 0
grpc-advanced-compat src/grpc-advanced/grpc-advanced-compat COMPLETE 24 1 0
grpc-advanced-diagnostics src/grpc-advanced/grpc-advanced-diagnostics COMPLETE 7 1 0

F. 분석 과정 기록

분석 중에는 notes/ 였다. 무엇을 어디까지 어떻게 확인했는지의 기록이다.

Material production FULL_READ completion gate

  • reopened at: 2026-08-31T23:30:00+09:00
  • affected leaves: 23
  • reason: behavior-bearing production implementation remained STRUCTURAL_ONLY or was explicitly recorded as not fully read.
  • COMPLETE rule: behavior-bearing src/main implementation must be read and incorporated into the SSOT.
  • STRUCTURAL_ONLY remains valid only for generated/declarative/non-semantic repetitive artifacts when the ledger records the reason.
  • tests may be selectively read when the document explicitly states what was and was not inspected, but tests supporting a material claim must be read at the assertion/fixture path used by that claim.
  • downstream order remains: all module SSOTs COMPLETE -> 99-cross-scope -> final/document.md -> candidate ledger/source manifest -> Root Tree.
Reopened leaves
  • messaging-kafka
  • messaging-rabbit
  • messaging-pulsar-experimental
  • messaging-nats-experimental
  • messaging-spring-boot-starter
  • grpc-core-api
  • grpc-proto-contract
  • grpc-codegen
  • grpc-policy
  • grpc-server
  • grpc-client
  • grpc-discovery
  • grpc-admin
  • grpc-observability
  • grpc-operation-ledger-jpa
  • grpc-spring-boot-starter
  • grpc-testkit
  • grpc-advanced-bootstrap
  • grpc-advanced-edition
  • grpc-advanced-streaming
  • grpc-advanced-resilience
  • grpc-advanced-compat
  • grpc-advanced-diagnostics

Root Tree coverage rebuild — 2026-08-31

  • pre-rebuild checkpoint: checkpoints/root-tree-before-recall-gate-20260831.md
  • explicit priority findings: 200
  • findings merged to an existing explicit Case: 38
  • repeated-analysis duplicate findings merged: 7
  • new Case nodes emitted: 155
  • baseline ledger entries for pre-existing untargeted nodes: 135
  • ledger total: 335
  • unmapped: 0
  • root-tree nodes after expansion: 324
  • root-tree topics after expansion: 25

Merge policy: an existing Topic/Reference or a broad source section is not sufficient evidence for merging. Only the same incident/verification unit is merged.

Kind correction / explicit-question recall
  • explicit analysis candidates: 205 (P1/P2/P3 200 + cross-scope unprioritized questions 5)
  • reclassified generated findings: 10 (9 Open Question, 1 Reference)
  • cross-scope questions: 3 MERGED, 1 BLOCKED by explicit scope exclusion, 1 new Open Question
  • root-tree nodes after correction: 325
  • ledger candidates after correction: 340
  • unmapped: 0
Completion
  • rootTree.status: COMPLETE
  • coverageStatus: VERIFIED_COMPLETE
  • analysis queue project: COMPLETE
  • analysis queue activeProject: null
  • completion requires final full-workspace verifier pass recorded after this state transition.

Module SSOT depth audit

  • audit date: 2026-08-31
  • source revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
  • included registered leaves: 61
  • excluded leaves: 1 (sample-portfolio)
  • cycle 1 canonical analysis files for 61 leaves: 20 unique files
  • structural defect: §A19 was shared by 25 leaves, §A20 by 18 leaves.
  • target: 61 distinct canonical module SSOTs, plus optional family/integration documents.
판단

현재 문서 세트는 코드베이스 분석과 Root Tree 후보 추출에는 많은 근거를 제공하지만, 각 registered leaf의 장기 Tech Log SSOT라는 새 요구사항은 충족하지 못한다. 특히 family 문서 두 개는 개별 leaf 책임·계약·실패 경로·테스트 증명 범위를 독립적으로 재개방하기 어렵다.

문서 길이를 persistence-jpa와 동일하게 맞추지는 않는다. 대신 persistence-jpa가 보여준 다음 성질을 기준으로 한다: 측정 가능한 denominator, 내부 package/sub-scope 지도, contract/invariant, 실제 wiring, success/failure mechanics, test가 증명하는 범위, negative-space probes, history/rationale, findings backlog, limitations, exact source/evidence provenance.

application-core는 1,021 source/test 파일을 FULL_READ로 기록했지만 379줄 문서 한 개가 23 top-level package를 압축하고 있어 module SSOT로는 보강이 필요하다. shared-contract도 82 FULL_READ에 161줄로 주요 contract를 요약해 두어 같은 이유로 보강 대상으로 둔다. 반대로 domain-core처럼 production 7개 수준의 작은 leaf는 200여 줄이어도 충분할 수 있다.

Cycle 2 review matrix
leaf source path review cycle-1 document size/coverage reason
domain-core src/domain-core REVALIDATE_EXISTING_SSOT 215 lines; full 10 / structural 3 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
shared-contract src/shared-contract DEEPEN_REQUIRED 161 lines; full 82 / structural 4 단일 leaf 문서는 있으나 source 규모 대비 설명이 요약형; claim-level 추적/내부 sub-scope 보강 필요
application-core src/application-core DEEPEN_REQUIRED 379 lines; full 1021 / structural 0 단일 leaf 문서는 있으나 source 규모 대비 설명이 요약형; claim-level 추적/내부 sub-scope 보강 필요
adapter-outbound-support src/adapter/outbound/support REVALIDATE_EXISTING_SSOT 645 lines; full 8 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-outbound-persistence-jpa src/adapter/outbound/persistence-jpa REVALIDATE_EXISTING_SSOT 4674 lines; full 605 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-outbound-persistence-mongo src/adapter/outbound/persistence-mongo REVALIDATE_EXISTING_SSOT 1496 lines; full 497 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-outbound-identifier src/adapter/outbound/identifier REVALIDATE_EXISTING_SSOT 188 lines; full 10 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-outbound-fileserver src/adapter/outbound/fileserver REVALIDATE_EXISTING_SSOT 656 lines; full 119 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-outbound-objectstorage src/adapter/outbound/objectstorage REVALIDATE_EXISTING_SSOT 793 lines; full 206 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-outbound-cache-redis src/adapter/outbound/cache-redis REVALIDATE_EXISTING_SSOT 933 lines; full 390 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-outbound-httpclient src/adapter/outbound/httpclient REVALIDATE_EXISTING_SSOT 716 lines; full 370 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-outbound-messaging src/adapter/outbound/messaging REVALIDATE_EXISTING_SSOT 379 lines; full 69 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-outbound-notification src/adapter/outbound/notification REVALIDATE_EXISTING_SSOT 1062 lines; full 240 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-inbound-web src/adapter/inbound/web REVALIDATE_EXISTING_SSOT 1702 lines; full 638 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-inbound-grpc src/adapter/inbound/grpc REVALIDATE_EXISTING_SSOT 244 lines; full 18 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-inbound-graphql src/adapter/inbound/graphql REVALIDATE_EXISTING_SSOT 1122 lines; full 534 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
adapter-inbound-websocket src/adapter/inbound/websocket REVALIDATE_EXISTING_SSOT 546 lines; full 253 / structural 0 개별 SSOT 존재; cycle 2에서 새 completion gate 기준으로 재검증
app-bootstrap src/app-bootstrap DEEPEN_REVIEW 571 lines; full 455 / structural 0 개별 SSOT는 있으나 큰 composition leaf라 cycle 2에서 깊이 재검증 필요
sample-portfolio src/sample-portfolio EXCLUDED - 사용자 지시(2026-08-30): 샘플 모듈은 분석 대상에서 제외.
messaging-core-api src/messaging/messaging-core-api SPLIT_REQUIRED 1284 lines; full 46 / structural 49 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-schema-api src/messaging/messaging-schema-api SPLIT_REQUIRED 1284 lines; full 13 / structural 2 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-schema-json src/messaging/messaging-schema-json SPLIT_REQUIRED 1284 lines; full 5 / structural 1 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-schema-avro src/messaging/messaging-schema-avro SPLIT_REQUIRED 1284 lines; full 5 / structural 3 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-schema-protobuf src/messaging/messaging-schema-protobuf SPLIT_REQUIRED 1284 lines; full 5 / structural 1 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-cloudevents src/messaging/messaging-cloudevents SPLIT_REQUIRED 1284 lines; full 6 / structural 0 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-policy src/messaging/messaging-policy SPLIT_REQUIRED 1284 lines; full 18 / structural 14 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-transport-spi src/messaging/messaging-transport-spi SPLIT_REQUIRED 1284 lines; full 19 / structural 0 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-runtime-core src/messaging/messaging-runtime-core SPLIT_REQUIRED 1284 lines; full 12 / structural 0 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-observability src/messaging/messaging-observability SPLIT_REQUIRED 1284 lines; full 13 / structural 4 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-security src/messaging/messaging-security SPLIT_REQUIRED 1284 lines; full 14 / structural 3 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-kafka src/messaging/messaging-kafka SPLIT_REQUIRED 1284 lines; full 24 / structural 37 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-kafka-share-experimental src/messaging/messaging-kafka-share-experimental SPLIT_REQUIRED 1284 lines; full 5 / structural 2 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-rabbit src/messaging/messaging-rabbit SPLIT_REQUIRED 1284 lines; full 18 / structural 15 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-reliability-api src/messaging/messaging-reliability-api SPLIT_REQUIRED 1284 lines; full 15 / structural 0 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-outbox-jdbc-postgresql src/messaging/messaging-outbox-jdbc-postgresql SPLIT_REQUIRED 1284 lines; full 16 / structural 12 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-inbox-jdbc-postgresql src/messaging/messaging-inbox-jdbc-postgresql SPLIT_REQUIRED 1284 lines; full 9 / structural 4 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-claim-check src/messaging/messaging-claim-check SPLIT_REQUIRED 1284 lines; full 7 / structural 4 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-admin-api src/messaging/messaging-admin-api SPLIT_REQUIRED 1284 lines; full 10 / structural 18 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-admin-runtime src/messaging/messaging-admin-runtime SPLIT_REQUIRED 1284 lines; full 8 / structural 12 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-pulsar-experimental src/messaging/messaging-pulsar-experimental SPLIT_REQUIRED 1284 lines; full 7 / structural 5 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-nats-experimental src/messaging/messaging-nats-experimental SPLIT_REQUIRED 1284 lines; full 6 / structural 5 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-spring-cloud-stream-bridge src/messaging/messaging-spring-cloud-stream-bridge SPLIT_REQUIRED 1284 lines; full 7 / structural 3 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-spring-boot-starter src/messaging/messaging-spring-boot-starter SPLIT_REQUIRED 1284 lines; full 26 / structural 18 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
messaging-testkit src/messaging/messaging-testkit SPLIT_REQUIRED 1284 lines; full 16 / structural 7 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-core-api src/grpc/grpc-core-api SPLIT_REQUIRED 716 lines; full 3 / structural 38 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-proto-contract src/grpc/grpc-proto-contract SPLIT_REQUIRED 716 lines; full 0 / structural 11 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-codegen src/grpc/grpc-codegen SPLIT_REQUIRED 716 lines; full 0 / structural 16 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-policy src/grpc/grpc-policy SPLIT_REQUIRED 716 lines; full 21 / structural 61 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-server src/grpc/grpc-server SPLIT_REQUIRED 716 lines; full 2 / structural 22 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-client src/grpc/grpc-client SPLIT_REQUIRED 716 lines; full 2 / structural 17 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-discovery src/grpc/grpc-discovery SPLIT_REQUIRED 716 lines; full 1 / structural 10 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-admin src/grpc/grpc-admin SPLIT_REQUIRED 716 lines; full 1 / structural 18 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-observability src/grpc/grpc-observability SPLIT_REQUIRED 716 lines; full 2 / structural 5 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-operation-ledger-jpa src/grpc/grpc-operation-ledger-jpa SPLIT_REQUIRED 716 lines; full 0 / structural 7 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-spring-boot-starter src/grpc/grpc-spring-boot-starter SPLIT_REQUIRED 716 lines; full 3 / structural 5 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-testkit src/grpc/grpc-testkit SPLIT_REQUIRED 716 lines; full 4 / structural 32 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-advanced-bootstrap src/grpc-advanced/grpc-advanced-bootstrap SPLIT_REQUIRED 716 lines; full 1 / structural 12 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-advanced-edition src/grpc-advanced/grpc-advanced-edition SPLIT_REQUIRED 716 lines; full 0 / structural 11 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-advanced-streaming src/grpc-advanced/grpc-advanced-streaming SPLIT_REQUIRED 716 lines; full 1 / structural 19 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-advanced-resilience src/grpc-advanced/grpc-advanced-resilience SPLIT_REQUIRED 716 lines; full 0 / structural 23 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-advanced-compat src/grpc-advanced/grpc-advanced-compat SPLIT_REQUIRED 716 lines; full 2 / structural 23 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
grpc-advanced-diagnostics src/grpc-advanced/grpc-advanced-diagnostics SPLIT_REQUIRED 716 lines; full 0 / structural 8 family 문서가 여러 registered leaf를 동시에 소유함; 개별 canonical SSOT 필요
Completion rule
  1. 각 included leaf는 state.json.scopes[].analysisFile이 서로 다른 canonical 문서를 가리킨다.
  2. family/platform 문서는 INTEGRATION_ONLY이며 leaf SSOT를 대체하지 않는다.
  3. 큰 leaf는 canonical 문서 + leaf-owned subdocument로 나눌 수 있다.
  4. 각 canonical SSOT는 deep-analysis-standard.md의 completion gate를 충족한다.
  5. 61개 leaf가 모두 COMPLETE 된 뒤 99-cross-scope.mdfinal/document.md → Root Tree를 다시 생성한다.
  6. 그 전에는 Tech Log record generation을 금지한다.